feat: affiliates router — wrap_url() with opt-out, BYOK, and CF env-var resolution

This commit is contained in:
pyr0ball 2026-04-04 18:20:21 -07:00
parent 73cec07bd2
commit 7837fbcad2
2 changed files with 197 additions and 0 deletions

View file

@ -0,0 +1,83 @@
"""Affiliate URL wrapping — resolution logic.
Resolution order (from affiliate links design doc):
1. User opted out? return plain URL
2. User has BYOK ID for this retailer? wrap with user's ID
3. CF has a program with env var set? wrap with CF's ID
4. No program / no ID configured return plain URL
The ``get_preference`` callable is optional. When None (default), steps 1
and 2 are skipped the module operates in env-var-only mode. Products
inject their preferences client to enable opt-out and BYOK.
Signature of ``get_preference``::
def get_preference(user_id: str | None, path: str, default=None) -> Any: ...
"""
from __future__ import annotations
import logging
from typing import Any, Callable
from .programs import get_program
logger = logging.getLogger(__name__)
GetPreferenceFn = Callable[[str | None, str, Any], Any]
def wrap_url(
url: str,
retailer: str,
user_id: str | None = None,
get_preference: GetPreferenceFn | None = None,
) -> str:
"""Return an affiliate URL for *url*, or the plain URL if no affiliate
link can be or should be generated.
Args:
url: Plain product URL to wrap.
retailer: Retailer key (e.g. ``"ebay"``, ``"amazon"``).
user_id: User identifier for preference lookups. None = anonymous.
get_preference: Optional callable ``(user_id, path, default) -> value``.
Injected by products to enable opt-out and BYOK resolution.
When None, opt-out and BYOK checks are skipped.
Returns:
Affiliate URL, or *url* unchanged if:
- The user has opted out
- No program is registered for *retailer*
- No affiliate ID is configured (env var unset and no BYOK)
"""
program = get_program(retailer)
if program is None:
logger.debug("affiliates: no program registered for retailer=%r", retailer)
return url
# Step 1: opt-out check
if get_preference is not None:
opted_out = get_preference(user_id, "affiliate.opt_out", False)
if opted_out:
logger.debug("affiliates: user %r opted out — returning plain URL", user_id)
return url
# Step 2: BYOK — user's own affiliate ID (Premium)
if get_preference is not None and user_id is not None:
byok_id = get_preference(user_id, f"affiliate.byok_ids.{retailer}", None)
if byok_id:
logger.debug(
"affiliates: using BYOK id for user=%r retailer=%r", user_id, retailer
)
return program.build_url(url, byok_id)
# Step 3: CF's affiliate ID from env var
cf_id = program.cf_affiliate_id()
if cf_id:
return program.build_url(url, cf_id)
logger.debug(
"affiliates: no affiliate ID configured for retailer=%r (env var %r unset)",
retailer, program.env_var,
)
return url

View file

@ -0,0 +1,114 @@
"""Tests for affiliate URL wrapping resolution logic."""
import pytest
from circuitforge_core.affiliates.router import wrap_url
def _pref_store(prefs: dict):
"""Return a get_preference callable backed by a plain dict."""
def get_preference(user_id, path, default=None):
keys = path.split(".")
node = prefs
for k in keys:
if not isinstance(node, dict):
return default
node = node.get(k)
if node is None:
return default
return node
return get_preference
class TestWrapUrlEnvVarMode:
"""No get_preference injected — env-var-only mode."""
def test_returns_affiliate_url_when_env_set(self, monkeypatch):
monkeypatch.setenv("EBAY_AFFILIATE_CAMPAIGN_ID", "camp123")
result = wrap_url("https://www.ebay.com/itm/1", retailer="ebay")
assert "campid=camp123" in result
def test_returns_plain_url_when_env_unset(self, monkeypatch):
monkeypatch.delenv("EBAY_AFFILIATE_CAMPAIGN_ID", raising=False)
result = wrap_url("https://www.ebay.com/itm/1", retailer="ebay")
assert result == "https://www.ebay.com/itm/1"
def test_returns_plain_url_for_unknown_retailer(self, monkeypatch):
monkeypatch.setenv("EBAY_AFFILIATE_CAMPAIGN_ID", "camp123")
result = wrap_url("https://www.example.com/item/1", retailer="unknown_shop")
assert result == "https://www.example.com/item/1"
def test_amazon_env_var(self, monkeypatch):
monkeypatch.setenv("AMAZON_ASSOCIATES_TAG", "cf-kiwi-20")
result = wrap_url("https://www.amazon.com/dp/B001", retailer="amazon")
assert "tag=cf-kiwi-20" in result
class TestWrapUrlOptOut:
"""get_preference injected — opt-out enforcement."""
def test_opted_out_returns_plain_url(self, monkeypatch):
monkeypatch.setenv("EBAY_AFFILIATE_CAMPAIGN_ID", "camp123")
get_pref = _pref_store({"affiliate": {"opt_out": True}})
result = wrap_url(
"https://www.ebay.com/itm/1", retailer="ebay",
user_id="u1", get_preference=get_pref,
)
assert result == "https://www.ebay.com/itm/1"
def test_opted_in_returns_affiliate_url(self, monkeypatch):
monkeypatch.setenv("EBAY_AFFILIATE_CAMPAIGN_ID", "camp123")
get_pref = _pref_store({"affiliate": {"opt_out": False}})
result = wrap_url(
"https://www.ebay.com/itm/1", retailer="ebay",
user_id="u1", get_preference=get_pref,
)
assert "campid=camp123" in result
def test_no_preference_set_defaults_to_opted_in(self, monkeypatch):
"""Missing opt_out key = opted in (default behaviour per design doc)."""
monkeypatch.setenv("EBAY_AFFILIATE_CAMPAIGN_ID", "camp123")
get_pref = _pref_store({})
result = wrap_url(
"https://www.ebay.com/itm/1", retailer="ebay",
user_id="u1", get_preference=get_pref,
)
assert "campid=camp123" in result
class TestWrapUrlByok:
"""BYOK affiliate ID takes precedence over CF's ID."""
def test_byok_id_used_instead_of_cf_id(self, monkeypatch):
monkeypatch.setenv("EBAY_AFFILIATE_CAMPAIGN_ID", "cf-camp")
get_pref = _pref_store({
"affiliate": {
"opt_out": False,
"byok_ids": {"ebay": "user-own-camp"},
}
})
result = wrap_url(
"https://www.ebay.com/itm/1", retailer="ebay",
user_id="u1", get_preference=get_pref,
)
assert "campid=user-own-camp" in result
assert "cf-camp" not in result
def test_byok_only_used_when_present(self, monkeypatch):
monkeypatch.setenv("EBAY_AFFILIATE_CAMPAIGN_ID", "cf-camp")
get_pref = _pref_store({"affiliate": {"opt_out": False, "byok_ids": {}}})
result = wrap_url(
"https://www.ebay.com/itm/1", retailer="ebay",
user_id="u1", get_preference=get_pref,
)
assert "campid=cf-camp" in result
def test_byok_without_user_id_not_applied(self, monkeypatch):
"""BYOK requires a user_id — anonymous users get CF's ID."""
monkeypatch.setenv("EBAY_AFFILIATE_CAMPAIGN_ID", "cf-camp")
get_pref = _pref_store({
"affiliate": {"opt_out": False, "byok_ids": {"ebay": "user-own-camp"}}
})
result = wrap_url(
"https://www.ebay.com/itm/1", retailer="ebay",
user_id=None, get_preference=get_pref,
)
assert "campid=cf-camp" in result