feat: affiliates router — wrap_url() with opt-out, BYOK, and CF env-var resolution
This commit is contained in:
parent
73cec07bd2
commit
7837fbcad2
2 changed files with 197 additions and 0 deletions
83
circuitforge_core/affiliates/router.py
Normal file
83
circuitforge_core/affiliates/router.py
Normal 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
|
||||
114
tests/test_affiliates/test_router.py
Normal file
114
tests/test_affiliates/test_router.py
Normal 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
|
||||
Loading…
Reference in a new issue