Restore tracker mapping linking, drop only create

The tracker-mapping worker had been reduced to catalog resolution only,
which removed not just the auto-creation of an org ThirdParty but also
the auto-linking of an existing one. Only the creation needed to go: it
raced the load-then-create check and produced duplicate vendors.

Restore the full org ThirdParty resolution (exact common-id link,
sibling direct-link, high-confidence heuristic, and the disambiguation
agent) and remove only the CreateFromCommon branch and its
categorisation gate. When nothing matches, the worker now leaves
third_party_id unset rather than creating a vendor; creation happens
exclusively through the explicit ImportFromCommon action. Drop the
now-dead CreateFromCommon helper and rename match.go to common_match.go.

Fix a latent test bug surfaced by actually running the DB-backed suite
(skipped in CI without Postgres): the heuristic-match candidate lacked
Level 1, so the level-filtered candidate loader excluded it and the old
fallback create masked the miss.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-11 10:55:29 +02:00
parent 9a7bc26d49
commit 145aeaf402
17 changed files with 1323 additions and 238 deletions

View File

@@ -230,9 +230,10 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
return fmt.Errorf("cannot promote source on glob pattern %q: %w", key.template, err)
}
// A stronger source can unblock mapping (e.g.
// EXTENSION->SCRIPT lifts the creationAllowed
// gate), so re-arm mapping on the existing glob.
// A stronger source can unblock mapping (a fresh
// initiator domain lets matchByDomain/
// matchBySiblingOrigin resolve a vendor), so
// re-arm mapping on the existing glob.
if err := globPattern.SetMappingRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot request mapping after source promotion on glob pattern %q: %w", key.template, err)
}
@@ -894,9 +895,9 @@ func (h *patternAnalysisHandler) adoptUncategorisedPatterns(
return false, fmt.Errorf("cannot promote source on glob pattern %q: %w", match.Pattern, err)
}
// A stronger source can unblock mapping (e.g.
// EXTENSION->SCRIPT lifts the creationAllowed gate), so
// re-arm mapping on the adopted glob.
// A stronger source can unblock mapping (a fresh
// initiator domain lets matchByDomain/matchBySiblingOrigin
// resolve a vendor), so re-arm mapping on the adopted glob.
if err := match.SetMappingRequested(ctx, tx); err != nil {
return false, fmt.Errorf("cannot request mapping after source promotion on glob pattern %q: %w", match.Pattern, err)
}

View File

@@ -2284,9 +2284,8 @@ func (s *Service) reportDetectedTracker(
// A stronger source can unblock mapping: the detection
// upserted below carries a fresh initiator domain that
// matchByDomain/matchBySiblingOrigin can now use, and an
// EXTENSION->SCRIPT promotion lifts the creationAllowed
// gate. Re-arm mapping so the worker revisits the pattern.
// matchByDomain/matchBySiblingOrigin can now use. Re-arm
// mapping so the worker revisits the pattern.
if err := matchedPattern.SetMappingRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot request mapping after source promotion on tracker pattern %q: %w", matchedPattern.Pattern, err)
}

View File

@@ -39,17 +39,20 @@ import (
const defaultMappingStaleAfter = 10 * time.Minute
type trackerMappingHandler struct {
pg *pg.Client
logger *log.Logger
mappingAgent *agent.Agent
agentTimeout time.Duration
staleAfter time.Duration
pg *pg.Client
logger *log.Logger
mappingAgent *agent.Agent
disambiguationAgent *agent.Agent
agentTimeout time.Duration
disambiguationTimeout time.Duration
staleAfter time.Duration
}
func NewTrackerMappingWorker(
pgClient *pg.Client,
logger *log.Logger,
mappingCfg TrackerMappingAgentConfig,
disambiguationCfg thirdparty.DisambiguationAgentConfig,
staleAfter time.Duration,
opts ...worker.Option,
) *worker.Worker[coredata.TrackerPattern] {
@@ -63,16 +66,21 @@ func NewTrackerMappingWorker(
}
h := &trackerMappingHandler{
pg: pgClient,
logger: logger,
agentTimeout: agentTimeout,
staleAfter: staleAfter,
pg: pgClient,
logger: logger,
agentTimeout: agentTimeout,
disambiguationTimeout: disambiguationCfg.Timeout,
staleAfter: staleAfter,
}
if mappingCfg.LLMClient != nil {
h.mappingAgent = buildTrackerMappingAgent(mappingCfg, pgClient, logger)
}
if disambiguationCfg.LLMClient != nil {
h.disambiguationAgent = thirdparty.BuildDisambiguationAgent(disambiguationCfg, logger)
}
return worker.New(
"tracker-mapping-worker",
h,
@@ -124,11 +132,14 @@ func (h *trackerMappingHandler) RecoverStale(ctx context.Context) error {
// catalogMatch is the result of a single catalog signal. commonPatternID
// is the catalog row the signal resolved (or backfilled); commonThirdPartyID
// is the catalog third party the signal discovered, when any. A nil
// *catalogMatch means the signal produced nothing.
// is the catalog third party the signal discovered, when any; thirdPartyID
// is an existing org ThirdParty the signal knows directly (e.g. a sibling
// pattern already promoted in the same organization). A nil *catalogMatch
// means the signal produced nothing.
type catalogMatch struct {
commonPatternID *gid.GID
commonThirdPartyID *gid.GID
thirdPartyID *gid.GID
}
// Process resolves the catalog mapping for a tracker pattern and links it
@@ -145,11 +156,10 @@ type catalogMatch struct {
// common_tracker_pattern_id but its catalog row has no common third
// party yet.
//
// The worker no longer materializes per-org ThirdParty rows: it resolves
// the shared catalog link only. An org ThirdParty is created exclusively
// through the explicit per-vendor import action, which also backfills
// tracker_patterns.third_party_id; an already-set third_party_id is
// preserved here untouched.
// Org ThirdParty resolution only links to an existing party (even for
// uncategorised or extension-sourced patterns); it never creates a brand
// new org ThirdParty. Creating an org ThirdParty from a catalog vendor is
// done exclusively through the explicit ImportFromCommon action.
func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.TrackerPattern) error {
scope := coredata.NewScopeFromObjectID(tp.ID)
@@ -174,6 +184,7 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker
commonPatternID := det.commonPatternID
commonThirdPartyID := det.commonThirdPartyID
directThirdPartyID := det.directThirdPartyID
// Phase 2: tracker-mapping agent (no transaction). It runs only when
// the deterministic signals could not resolve a catalog third party.
@@ -205,6 +216,25 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker
}
}
// Phase 3: org ThirdParty resolution. The heuristic ranking and the
// disambiguation agent run without a transaction; only the final link
// touches the database (in a short transaction).
thirdPartyID := tp.ThirdPartyID
if thirdPartyID == nil {
switch {
case directThirdPartyID != nil:
thirdPartyID = directThirdPartyID
case commonThirdPartyID != nil:
resolved, err := h.resolveOrgThirdParty(ctx, tp, *commonThirdPartyID)
if err != nil {
return fmt.Errorf("cannot resolve org third party: %w", err)
}
thirdPartyID = resolved
}
}
// Phase 4: persist the pattern mapping in a short transaction. The
// unmatched fallback keeps catalog coverage complete even when no
// vendor was resolved.
@@ -221,6 +251,7 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker
}
tp.CommonTrackerPatternID = commonPatternID
tp.ThirdPartyID = thirdPartyID
tp.UpdatedAt = time.Now()
// Descriptions are owned by the common-pattern enrichment
@@ -290,6 +321,7 @@ type deterministicResult struct {
origin string
commonPatternID *gid.GID
commonThirdPartyID *gid.GID
directThirdPartyID *gid.GID
domains []string
commonThirdPartyPreexisted bool
}
@@ -371,6 +403,7 @@ func (h *trackerMappingHandler) resolveDeterministic(
if siblingMatch != nil {
res.commonPatternID = firstNonNil(res.commonPatternID, siblingMatch.commonPatternID)
res.commonThirdPartyID = siblingMatch.commonThirdPartyID
res.directThirdPartyID = siblingMatch.thirdPartyID
}
if res.commonThirdPartyID != nil {
@@ -805,8 +838,10 @@ func (h *trackerMappingHandler) persistAgentIdentification(
// matchBySiblingOrigin finds other tracker patterns on the same banner
// that share initiator domains with the current pattern. Sharing an
// origin across multiple detected patterns is a strong indicator of the
// same third party, so the common third party the siblings resolve to is
// upserted onto the catalog row.
// same third party. When the siblings resolve to a single existing org
// ThirdParty, that id is returned directly so promotion can link to it
// without re-running heuristics; otherwise the resolved common third
// party is upserted onto the catalog row.
func (h *trackerMappingHandler) matchBySiblingOrigin(
ctx context.Context,
tx pg.Tx,
@@ -837,14 +872,19 @@ func (h *trackerMappingHandler) matchBySiblingOrigin(
scope := coredata.NewScopeFromObjectID(tp.ID)
commonThirdPartyID, err := h.resolveThirdPartyFromSiblings(ctx, tx, scope, siblingIDs)
commonThirdPartyID, thirdPartyID, err := h.resolveThirdPartyFromSiblings(ctx, tx, scope, siblingIDs)
if err != nil {
return nil, fmt.Errorf("cannot resolve third party from siblings: %w", err)
}
// No catalog third party to record: leave catalog creation to a later
// signal or the unmatched fallback.
// No catalog third party to record: surface a directly-known org
// third party (if any) so promotion can still link to it, and leave
// catalog creation to a later signal or the unmatched fallback.
if commonThirdPartyID == nil {
if thirdPartyID != nil {
return &catalogMatch{thirdPartyID: thirdPartyID}, nil
}
return nil, nil
}
@@ -876,25 +916,37 @@ func (h *trackerMappingHandler) matchBySiblingOrigin(
return &catalogMatch{
commonPatternID: &commonPattern.ID,
commonThirdPartyID: commonPattern.CommonThirdPartyID,
thirdPartyID: thirdPartyID,
}, nil
}
// resolveThirdPartyFromSiblings inspects sibling patterns to resolve a
// single unambiguous catalog third party for backfill. It is resolved
// first from the siblings' org ThirdParties, then, when those carry none,
// from siblings' common_tracker_pattern rows. It returns nil when the
// siblings carry no catalog third party or disagree on one.
// third party. It returns two independent signals: a direct org
// ThirdParty (set only when the siblings share a single one — the
// strongest, same-org signal), and a single unambiguous catalog third
// party for backfill. The catalog third party is resolved first from the
// siblings' org ThirdParties, then, when those carry none, from siblings'
// common_tracker_pattern rows. Either signal may be nil; siblings that
// disagree on the catalog third party resolve it to nothing.
func (h *trackerMappingHandler) resolveThirdPartyFromSiblings(
ctx context.Context,
conn pg.Querier,
scope coredata.Scoper,
siblingIDs []gid.GID,
) (commonThirdPartyID *gid.GID, err error) {
) (commonThirdPartyID *gid.GID, thirdPartyID *gid.GID, err error) {
var patterns coredata.TrackerPatterns
thirdPartyIDs, err := patterns.LoadDistinctThirdPartyIDsByIDs(ctx, conn, scope, siblingIDs)
if err != nil {
return nil, fmt.Errorf("cannot load distinct third party ids from siblings: %w", err)
return nil, nil, fmt.Errorf("cannot load distinct third party ids from siblings: %w", err)
}
// A single org third party shared across the siblings is the
// strongest, same-org signal: link to it directly. This is resolved
// independently from the catalog third party used for backfill.
if len(thirdPartyIDs) == 1 {
directID := thirdPartyIDs[0]
thirdPartyID = &directID
}
if len(thirdPartyIDs) > 0 {
@@ -913,26 +965,29 @@ func (h *trackerMappingHandler) resolveThirdPartyFromSiblings(
if len(commonIDs) == 1 {
for id := range commonIDs {
return &id, nil
return &id, thirdPartyID, nil
}
}
// Siblings are linked to several different catalog third
// parties: do not guess one.
// Siblings are promoted to several different catalog third
// parties: do not guess one. A single shared org third party (if
// any) is still a safe direct link.
if len(commonIDs) > 1 {
return nil, nil
return nil, thirdPartyID, nil
}
}
// Fall back to siblings carrying only a common_tracker_pattern_id, or
// whose org ThirdParty is not itself linked to the catalog.
// whose org ThirdParty is not itself linked to the catalog. This is
// reached when the org-third-party scan above found no catalog third
// party, so it must not be short-circuited by a direct match.
commonPatternIDs, err := patterns.LoadDistinctCommonTrackerPatternIDsByIDs(ctx, conn, scope, siblingIDs)
if err != nil {
return nil, fmt.Errorf("cannot load distinct common tracker pattern ids from siblings: %w", err)
return nil, nil, fmt.Errorf("cannot load distinct common tracker pattern ids from siblings: %w", err)
}
if len(commonPatternIDs) == 0 {
return nil, nil
return nil, thirdPartyID, nil
}
commonIDs := make(map[gid.GID]struct{})
@@ -950,11 +1005,11 @@ func (h *trackerMappingHandler) resolveThirdPartyFromSiblings(
if len(commonIDs) == 1 {
for id := range commonIDs {
return &id, nil
return &id, thirdPartyID, nil
}
}
return nil, nil
return nil, thirdPartyID, nil
}
func (h *trackerMappingHandler) createUnmatchedPattern(
@@ -980,3 +1035,217 @@ func (h *trackerMappingHandler) createUnmatchedPattern(
return &commonPattern.ID, nil
}
// resolveOrgThirdParty resolves an org ThirdParty for the given pattern
// from a known catalog third party by linking to an existing party. The
// resolution order is:
//
// 1. Exact link by common_third_party_id (O(1)).
// 2. Heuristic match against the org's existing ThirdParty rows
// (lowercased name, suffix-stripped name, slug, website host,
// CommonThirdPartyDomain overlap).
// 3. Agent disambiguation when the heuristic is ambiguous.
//
// When none of these resolve an existing party, the function returns
// (nil, nil); it never creates a brand new org ThirdParty (that happens
// only through the explicit ImportFromCommon action). A confident
// heuristic/agent match is auto-tagged with common_third_party_id so
// subsequent resolutions hit the exact-link path in O(1).
func (h *trackerMappingHandler) resolveOrgThirdParty(
ctx context.Context,
tp coredata.TrackerPattern,
commonThirdPartyID gid.GID,
) (*gid.GID, error) {
scope := coredata.NewScopeFromObjectID(tp.ID)
// Read phase: exact link, candidate ranking, and eligibility. No
// write or LLM call happens here.
var prep orgThirdPartyPrep
if err := h.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
var err error
prep, err = h.prepareOrgThirdParty(ctx, conn, scope, tp, commonThirdPartyID)
return err
},
); err != nil {
return nil, err
}
if prep.existingID != nil {
return prep.existingID, nil
}
picked := prep.highConfidence
viaAgent := false
// Agent phase (no transaction): disambiguate among the heuristic
// candidates when none scored high enough on its own.
if picked == nil && prep.eligibleForAgent && h.disambiguationAgent != nil {
matchedID, err := thirdparty.Disambiguate(
ctx,
h.disambiguationAgent,
h.logger,
prep.commonParty,
prep.commonDomains,
prep.agentSet,
h.disambiguationTimeout,
)
if err != nil {
h.logger.WarnCtx(
ctx,
"third-party disambiguation agent failed",
log.Error(err),
log.String("tracker_pattern_id", tp.ID.String()),
)
}
if matchedID != nil {
for _, c := range prep.agentSet {
if c.ThirdParty.ID == *matchedID {
picked = c.ThirdParty
viaAgent = true
break
}
}
}
}
// Nothing to link: leave the pattern without an org third party. An
// org ThirdParty is created only through the explicit ImportFromCommon
// action, never here.
if picked == nil {
return nil, nil
}
// Write phase: link the picked candidate to the catalog entry in a
// short transaction.
if err := h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := thirdparty.LinkToCommon(ctx, tx, scope, picked, commonThirdPartyID); err != nil {
return fmt.Errorf("cannot link third party to common: %w", err)
}
if viaAgent {
h.logger.InfoCtx(
ctx,
"promoted tracker pattern via disambiguation agent",
log.String("tracker_pattern_id", tp.ID.String()),
log.String("third_party_id", picked.ID.String()),
)
} else {
h.logger.InfoCtx(
ctx,
"promoted tracker pattern via heuristic match",
log.String("tracker_pattern_id", tp.ID.String()),
log.String("third_party_id", picked.ID.String()),
log.Float64("score", prep.highScore),
)
}
return nil
},
); err != nil {
return nil, err
}
return &picked.ID, nil
}
// orgThirdPartyPrep is the read-phase outcome for org ThirdParty
// resolution. existingID is set when an exact common-id link already
// exists (the other fields are then unused). Otherwise highConfidence
// holds a heuristic match at or above HighConfidenceScore (with
// highScore), or agentSet/eligibleForAgent describe the disambiguation
// candidates.
type orgThirdPartyPrep struct {
existingID *gid.GID
commonParty coredata.CommonThirdParty
commonDomains coredata.CommonThirdPartyDomains
agentSet []thirdparty.ScoredCandidate
highConfidence *coredata.ThirdParty
highScore float64
eligibleForAgent bool
}
// prepareOrgThirdParty performs the read-only work for org ThirdParty
// resolution: it checks for an exact common-id link, loads the catalog
// entry and the org's existing third parties, and ranks the candidates.
// It makes no writes and no LLM call.
func (h *trackerMappingHandler) prepareOrgThirdParty(
ctx context.Context,
conn pg.Querier,
scope coredata.Scoper,
tp coredata.TrackerPattern,
commonThirdPartyID gid.GID,
) (orgThirdPartyPrep, error) {
var prep orgThirdPartyPrep
var existing coredata.ThirdParty
err := existing.LoadByOrganizationIDAndCommonThirdPartyID(
ctx,
conn,
scope,
tp.OrganizationID,
commonThirdPartyID,
)
if err == nil {
id := existing.ID
prep.existingID = &id
return prep, nil
}
if !errors.Is(err, coredata.ErrResourceNotFound) {
return prep, fmt.Errorf("cannot load org third party by common id: %w", err)
}
if err := prep.commonParty.LoadByID(ctx, conn, commonThirdPartyID); err != nil {
return prep, fmt.Errorf("cannot load common third party: %w", err)
}
if err := prep.commonDomains.LoadByCommonThirdPartyID(ctx, conn, commonThirdPartyID); err != nil {
return prep, fmt.Errorf("cannot load common third party domains: %w", err)
}
firstLevel := 1
var orgThirdParties coredata.ThirdParties
if err := orgThirdParties.LoadAllByOrganizationID(
ctx,
conn,
scope,
tp.OrganizationID,
coredata.NewThirdPartyFilter(nil, &firstLevel, nil),
); err != nil {
return prep, fmt.Errorf("cannot load org third parties: %w", err)
}
ranked := thirdparty.RankCandidates(prep.commonParty, prep.commonDomains, orgThirdParties)
if len(ranked) > 0 && ranked[0].Score >= thirdparty.HighConfidenceScore {
prep.highConfidence = ranked[0].ThirdParty
prep.highScore = ranked[0].Score
} else {
prep.agentSet = ranked
if len(prep.agentSet) > thirdparty.MaxAgentCandidates {
prep.agentSet = prep.agentSet[:thirdparty.MaxAgentCandidates]
}
for _, c := range prep.agentSet {
if c.Score >= thirdparty.MinAgentScore {
prep.eligibleForAgent = true
break
}
}
}
return prep, nil
}

View File

@@ -31,10 +31,10 @@ import (
// promotionFixture extends workerFixture with a CommonThirdParty and a
// CommonTrackerPattern linking the catalog to the test pattern. It is
// the minimum scaffolding the catalog-resolution paths need to run
// end-to-end.
// the minimum scaffolding resolveOrgThirdParty needs to run end-to-end.
type promotionFixture struct {
workerFixture
commonThirdParty coredata.CommonThirdParty
commonPatternID gid.GID
trackerPattern coredata.TrackerPattern
commonThirdPartyID gid.GID
@@ -130,6 +130,7 @@ func seedPromotionFixture(t *testing.T, ctx context.Context, client *pg.Client)
return promotionFixture{
workerFixture: fx,
commonThirdParty: commonThirdParty,
commonPatternID: commonPattern.ID,
commonThirdPartyID: commonThirdPartyID,
trackerPattern: pattern,
@@ -143,6 +144,112 @@ func newMappingHandler(client *pg.Client) *trackerMappingHandler {
}
}
// promote runs resolveOrgThirdParty, which manages its own short
// transactions internally and only links to an existing org ThirdParty
// (it never creates one).
func promote(
t *testing.T,
ctx context.Context,
h *trackerMappingHandler,
tp coredata.TrackerPattern,
commonThirdPartyID gid.GID,
) *gid.GID {
t.Helper()
got, err := h.resolveOrgThirdParty(ctx, tp, commonThirdPartyID)
require.NoError(t, err)
return got
}
func TestPromoteThirdParty_ExactCommonLink(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedPromotionFixture(t, ctx, client)
now := time.Now().UTC().Truncate(time.Microsecond)
existing := coredata.ThirdParty{
ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType),
OrganizationID: fx.organizationID,
CommonThirdPartyID: &fx.commonThirdPartyID,
Name: "Google LLC",
Category: coredata.ThirdPartyCategoryAnalytics,
Certifications: []string{},
Countries: coredata.CountryCodes{},
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return existing.Insert(ctx, tx, fx.scope)
}))
got := promote(t, ctx, newMappingHandler(client), fx.trackerPattern, fx.commonThirdPartyID)
require.NotNil(t, got)
assert.Equal(t, existing.ID, *got, "should return the existing org ThirdParty linked by common id")
}
func TestPromoteThirdParty_HeuristicMatch(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedPromotionFixture(t, ctx, client)
now := time.Now().UTC().Truncate(time.Microsecond)
// Append a corporate suffix to the catalog name so the heuristic
// matches on the suffix-stripped name (score 0.9) rather than an
// exact link.
manualEntry := coredata.ThirdParty{
ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType),
OrganizationID: fx.organizationID,
Name: fx.commonThirdParty.Name + " LLC",
Category: coredata.ThirdPartyCategoryAnalytics,
Certifications: []string{},
Countries: coredata.CountryCodes{},
Level: 1,
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return manualEntry.Insert(ctx, tx, fx.scope)
}))
got := promote(t, ctx, newMappingHandler(client), fx.trackerPattern, fx.commonThirdPartyID)
require.NotNil(t, got)
assert.Equal(t, manualEntry.ID, *got, "heuristic match should return the manually-entered ThirdParty")
var reloaded coredata.ThirdParty
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return reloaded.LoadByID(ctx, conn, fx.scope, manualEntry.ID)
}))
require.NotNil(t, reloaded.CommonThirdPartyID, "matched row must be tagged with common_third_party_id")
assert.Equal(t, fx.commonThirdPartyID, *reloaded.CommonThirdPartyID)
}
// TestPromoteThirdParty_NoCreateWithoutMatch asserts that when no
// existing org ThirdParty matches the catalog third party, resolution
// returns nothing: the worker never creates a brand new org ThirdParty
// (that is done only through the explicit ImportFromCommon action).
func TestPromoteThirdParty_NoCreateWithoutMatch(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedPromotionFixture(t, ctx, client)
got := promote(t, ctx, newMappingHandler(client), fx.trackerPattern, fx.commonThirdPartyID)
assert.Nil(t, got, "resolution must not create a new org ThirdParty")
}
// TestProcess_PreservesCatalogMappingOnReTrigger asserts that when
// Process is called for a pattern that already carries a
// common_tracker_pattern_id, the catalog pipeline is skipped and the
@@ -169,7 +276,7 @@ func TestProcess_PreservesCatalogMappingOnReTrigger(t *testing.T) {
require.NotNil(t, reloaded.CommonTrackerPatternID, "common tracker pattern link must be preserved")
assert.Equal(t, fx.commonPatternID, *reloaded.CommonTrackerPatternID)
assert.Nil(t, reloaded.ThirdPartyID, "the worker must not auto-create or link an org ThirdParty")
assert.Nil(t, reloaded.ThirdPartyID, "no org ThirdParty exists to link, so third_party_id stays unset")
}
// TestProcess_UncategorisedPatternIsNotPromoted asserts that a pattern
@@ -442,6 +549,8 @@ func TestMatchBySiblingOrigin_SiblingWithThirdPartyID(t *testing.T) {
require.NotNil(t, got, "sibling origin match should return a catalog match")
require.NotNil(t, got.commonPatternID, "sibling origin match should return a common tracker pattern ID")
require.NotNil(t, got.thirdPartyID, "sibling origin match should surface the sibling's org third party directly")
assert.Equal(t, orgThirdParty.ID, *got.thirdPartyID)
var commonPattern coredata.CommonTrackerPattern
@@ -874,11 +983,56 @@ func TestMatchBySiblingOrigin_ConvergentSiblings(t *testing.T) {
assert.Equal(t, fx.commonThirdPartyID, *commonPattern.CommonThirdPartyID)
}
func TestPromoteThirdParty_ExactCommonLinkIgnoresSimilarUnlinked(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedPromotionFixture(t, ctx, client)
now := time.Now().UTC().Truncate(time.Microsecond)
manualEntry := coredata.ThirdParty{
ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType),
OrganizationID: fx.organizationID,
Name: "Google LLC",
Category: coredata.ThirdPartyCategoryAnalytics,
Certifications: []string{},
Countries: coredata.CountryCodes{},
CreatedAt: now,
UpdatedAt: now,
}
linked := coredata.ThirdParty{
ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType),
OrganizationID: fx.organizationID,
CommonThirdPartyID: &fx.commonThirdPartyID,
Name: "Google",
Category: coredata.ThirdPartyCategoryAnalytics,
Certifications: []string{},
Countries: coredata.CountryCodes{},
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
if err := manualEntry.Insert(ctx, tx, fx.scope); err != nil {
return err
}
return linked.Insert(ctx, tx, fx.scope)
}))
got := promote(t, ctx, newMappingHandler(client), fx.trackerPattern, fx.commonThirdPartyID)
require.NotNil(t, got)
assert.Equal(t, linked.ID, *got, "exact-link path must short-circuit before the heuristic fires")
}
// TestProcess_BackfillsCommonThirdPartyFromSibling asserts that a pattern
// linked to an unlinked catalog row (no common_third_party_id) gets its
// catalog row backfilled from a sibling signal. The worker resolves the
// catalog link only; it must not promote the pattern to an org
// ThirdParty.
// catalog row backfilled from a sibling signal, and is promoted directly
// to the sibling's existing org ThirdParty.
func TestProcess_BackfillsCommonThirdPartyFromSibling(t *testing.T) {
t.Parallel()
@@ -1022,18 +1176,81 @@ func TestProcess_BackfillsCommonThirdPartyFromSibling(t *testing.T) {
return reloadedTarget.LoadByID(ctx, conn, fx.scope, target.ID)
}))
assert.Nil(t, reloadedTarget.ThirdPartyID, "target must not be auto-promoted to an org third party")
require.NotNil(t, reloadedTarget.ThirdPartyID, "target must be promoted to the sibling's org third party")
assert.Equal(t, orgThirdParty.ID, *reloadedTarget.ThirdPartyID)
require.NotNil(t, reloadedTarget.CommonTrackerPatternID)
assert.Equal(t, unlinkedCommon.ID, *reloadedTarget.CommonTrackerPatternID, "the existing catalog link must be preserved")
}
// TestProcess_SiblingCatalogResolutionOnFirstPartyOrigin asserts that a
// pattern detected on the banner's own (first-party) origin is still
// grouped with its siblings sharing that origin for catalog resolution.
// Sibling matching is an org-local co-occurrence signal and must not be
// defeated by the first-party domain filter that only protects the global
// catalog (domain) match.
func TestProcess_SiblingCatalogResolutionOnFirstPartyOrigin(t *testing.T) {
// TestProcess_UncategorisedLinksExistingThirdParty asserts that an
// uncategorised pattern is still linked to an already-existing matching
// org ThirdParty (linking to an existing party is ungated); only the
// creation of a new party stays gated, as covered by
// TestProcess_UncategorisedPatternIsNotPromoted.
func TestProcess_UncategorisedLinksExistingThirdParty(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
ctx := context.Background()
fx := seedPromotionFixture(t, ctx, client)
now := time.Now().UTC().Truncate(time.Microsecond)
existing := coredata.ThirdParty{
ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType),
OrganizationID: fx.organizationID,
CommonThirdPartyID: &fx.commonThirdPartyID,
Name: "Google LLC",
Category: coredata.ThirdPartyCategoryAnalytics,
Certifications: []string{},
Countries: coredata.CountryCodes{},
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
if err := existing.Insert(ctx, tx, fx.scope); err != nil {
return err
}
_, err := tx.Exec(
ctx,
`UPDATE tracker_patterns
SET cookie_category_id = $1,
mapping_requested_at = $2
WHERE id = $3`,
fx.uncategorisedID,
now,
fx.trackerPattern.ID,
)
return err
}))
var reloadedBefore coredata.TrackerPattern
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return reloadedBefore.LoadByID(ctx, conn, fx.scope, fx.trackerPattern.ID)
}))
h := newMappingHandler(client)
require.NoError(t, h.Process(ctx, reloadedBefore))
var reloaded coredata.TrackerPattern
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
return reloaded.LoadByID(ctx, conn, fx.scope, fx.trackerPattern.ID)
}))
require.NotNil(t, reloaded.ThirdPartyID, "uncategorised pattern must still link to an existing org third party")
assert.Equal(t, existing.ID, *reloaded.ThirdPartyID)
}
// TestProcess_SiblingPromotionOnFirstPartyOrigin asserts that a pattern
// detected on the banner's own (first-party) origin is still grouped with
// its siblings sharing that origin. Sibling matching is an org-local
// co-occurrence signal and must not be defeated by the first-party domain
// filter that only protects the global catalog (domain) match.
func TestProcess_SiblingPromotionOnFirstPartyOrigin(t *testing.T) {
t.Parallel()
client := test.PGClient(t)
@@ -1177,7 +1394,8 @@ func TestProcess_SiblingCatalogResolutionOnFirstPartyOrigin(t *testing.T) {
return reloadedTarget.LoadByID(ctx, conn, fx.scope, target.ID)
}))
assert.Nil(t, reloadedTarget.ThirdPartyID, "target must not be auto-promoted to an org third party")
require.NotNil(t, reloadedTarget.ThirdPartyID, "target sharing a first-party origin must be promoted via its sibling")
assert.Equal(t, orgThirdParty.ID, *reloadedTarget.ThirdPartyID)
}
// TestProcess_ReenqueuesUnmappedSiblingOnResolve asserts that when a
@@ -1295,8 +1513,7 @@ func TestProcess_ReenqueuesUnmappedSiblingOnResolve(t *testing.T) {
return reloadedTarget.LoadByID(ctx, conn, fx.scope, target.ID)
}))
require.NotNil(t, reloadedTarget.CommonTrackerPatternID, "target must resolve a catalog link via its sibling")
assert.Nil(t, reloadedTarget.ThirdPartyID, "target must not be auto-promoted to an org third party")
require.NotNil(t, reloadedTarget.ThirdPartyID, "target must resolve via its promoted sibling")
var reloadedUnmapped coredata.TrackerPattern
@@ -1450,8 +1667,7 @@ func TestProcess_DoesNotReenqueuePromotedOrExtensionSiblings(t *testing.T) {
return p
}
require.NotNil(t, reload(target.ID).CommonTrackerPatternID, "target must resolve a catalog link via its sibling")
assert.Nil(t, reload(target.ID).ThirdPartyID, "target must not be auto-promoted to an org third party")
require.NotNil(t, reload(target.ID).ThirdPartyID, "target must resolve via its promoted sibling")
require.NotNil(t, reload(plainSibling.ID).MappingRequestedAt, "plain unmapped sibling must be re-enqueued")
assert.Nil(t, reload(mappedSibling.ID).MappingRequestedAt, "promoted sibling must not be re-enqueued")
assert.Nil(t, reload(extensionSibling.ID).MappingRequestedAt, "EXTENSION-sourced sibling must not be re-enqueued")