diff --git a/circuitforge_core/affiliates/router.py b/circuitforge_core/affiliates/router.py new file mode 100644 index 0000000..a2f4c39 --- /dev/null +++ b/circuitforge_core/affiliates/router.py @@ -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 diff --git a/tests/test_affiliates/test_router.py b/tests/test_affiliates/test_router.py new file mode 100644 index 0000000..0552817 --- /dev/null +++ b/tests/test_affiliates/test_router.py @@ -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