eggfetch-python 0.1.4

Python sync and asyncio bindings for the eggfetch HTTP engine (Rust core via PyO3; Python users install from PyPI)
"""Adjacent lossy conversion audit tests.

Track 6.4: Verify that cookies with same name but different domain/path,
URL userinfo and percent encoding, multipart per-part headers, and
response history request attachment are not lossy.
"""

import pytest
from eggfetch.compat.httpx import (
    Client,
    MockTransport,
    QueryParams,
    Request,
    Response,
    URL,
)
from eggfetch.compat.httpx._cookies import Cookies
from eggfetch.compat.httpx._headers import Headers


class TestDuplicateCookiePreservation:
    """Cookies in the simplified compat layer are dict-backed.

    The compat Cookies class uses a simple dict, so cookies with the same
    name but different domain/path overwrite each other. This is a known
    Stage C boundary — the underlying eggfetch cookie jar handles domain/path
    correctly for real network requests.
    """

    def test_set_overwrites_same_name(self):
        """Cookies.set() with same name and same domain overwrites."""
        cookies = Cookies()
        cookies.set("session", "val1", domain=".example.com", path="/")
        cookies.set("session", "val2", domain=".example.com", path="/")
        items = list(cookies.items())
        # Same name + same domain + same path: last write wins
        assert len(items) == 1
        assert cookies.get("session") == "val2"

    def test_set_different_domain_preserves(self):
        """Cookies.set() with same name but different domains preserves both."""
        cookies = Cookies()
        cookies.set("session", "val1", domain=".example.com")
        cookies.set("session", "val2", domain=".api.example.com")
        items = list(cookies.items())
        # Different domains: both preserved (scoped jar behavior)
        assert len(items) == 2

    def test_different_names_preserved(self):
        """Different cookie names are preserved."""
        cookies = Cookies()
        cookies.set("session", "val1")
        cookies.set("token", "val2")
        items = list(cookies.items())
        assert len(items) == 2

    def test_duplicate_cookie_header_wired(self):
        """Multiple Set-Cookie headers are preserved in response."""
        def handler(request):
            return Response(
                200,
                headers=[
                    ("set-cookie", "a=1; path=/"),
                    ("set-cookie", "b=2; path=/api"),
                    ("set-cookie", "c=3; path=/other"),
                ],
            )

        with Client(transport=MockTransport(handler)) as client:
            resp = client.get("http://testserver/")
        # All three Set-Cookie headers should be present
        set_cookie_headers = resp.headers.multi_items()
        set_cookies = [v for k, v in set_cookie_headers if k.lower() == "set-cookie"]
        assert len(set_cookies) == 3


class TestURLPercentEncoding:
    """URL percent encoding and userinfo must not be lossy."""

    def test_percent_encoded_path_preserved(self):
        """Percent-encoded characters in path survive URL construction."""
        url = URL("http://example.com/path%20with%20spaces")
        assert "%20" in str(url) or "path%20with%20spaces" in str(url)

    def test_userinfo_preserved(self):
        """Userinfo in URL is preserved."""
        url = URL("http://user:pass@example.com/")
        assert url.username == "user"
        assert url.password == "pass"

    def test_query_params_ordering_and_duplicates(self):
        """Repeated query keys preserve order and multiplicity."""
        qp = QueryParams("a=1&a=2&b=&a=3")
        items = qp.multi_items()
        assert items == [("a", "1"), ("a", "2"), ("b", ""), ("a", "3")]

    def test_query_params_merge_preserves_order(self):
        """Merging QueryParams preserves original order."""
        base = QueryParams("x=1&y=2")
        extra = QueryParams("x=3&z=4")
        base.merge(extra)  # merge modifies in place
        items = base.multi_items()
        assert items == [("x", "1"), ("y", "2"), ("x", "3"), ("z", "4")]


class TestResponseHistoryAttachment:
    """Response history must preserve request objects."""

    def test_redirect_history_has_requests(self):
        """After redirects, history entries have request objects attached.

        Note: MockTransport does not follow redirects automatically.
        We verify that when a 302 is returned, the response preserves
        the original request reference.
        """
        def handler(request):
            if request.url.path == "/start":
                return Response(302, headers=[("location", "http://testserver/end")])
            return Response(200, text="done")

        with Client(transport=MockTransport(handler), follow_redirects=False) as client:
            resp = client.get("http://testserver/start")

        assert resp.status_code == 302
        assert resp.request is not None
        assert resp.request.url.path == "/start"


class TestDuplicateHeadersPreserved:
    """Duplicate raw header pairs must survive conversion."""

    def test_duplicate_set_cookie_independent(self):
        """Multiple Set-Cookie headers remain independently observable."""
        def handler(request):
            return Response(
                200,
                headers=[
                    ("set-cookie", "a=1"),
                    ("set-cookie", "b=2"),
                    ("set-cookie", "a=3"),
                ],
            )

        with Client(transport=MockTransport(handler)) as client:
            resp = client.get("http://testserver/")

        set_cookies = [v for k, v in resp.headers.multi_items() if k.lower() == "set-cookie"]
        assert len(set_cookies) == 3
        assert "a=1" in set_cookies
        assert "b=2" in set_cookies
        assert "a=3" in set_cookies

    def test_raw_header_ordering_preserved(self):
        """Raw header ordering is preserved through multi_items."""
        def handler(request):
            return Response(
                200,
                headers=[
                    ("x-first", "1"),
                    ("x-second", "2"),
                    ("x-first", "3"),
                ],
            )

        with Client(transport=MockTransport(handler)) as client:
            resp = client.get("http://testserver/")

        items = resp.headers.multi_items()
        first_headers = [(k, v) for k, v in items if k == "x-first"]
        assert first_headers == [("x-first", "1"), ("x-first", "3")]