Repository: encode/starlette

Issue:
TestClient doesn't merge urls in the same way as the httpx client, and I think it should.
### Discussed in https://github.com/encode/starlette/discussions/2300

<div type='discussions-op-text'>

<sup>Originally posted by **Waghabond** October 12, 2023</sup>
The TestClient just delegates the url merging to the join method of the httpx.URL object.

This causes the base path configuration to be ignored in some cases.

eg. 
This how the merge is done in the Starlette TestClient's request() method.
```python
url = self.base_url.join(url)
```
It creates unintuitive behaviour in the following example (which i think is a common way to use the test client)
```python
client = testclient.TestClient(app, base_url="http://testserver/api/v1/")

result = client.base_url.join("/this-should-append-onto-the-end-of-the-base-url")
# result in reality is URL('http://testserver/this-should-append-onto-the-end-of-the-base-url'), the /api/v1 has disappeared.
result = client.base_url.join("this-works-as-expected")
# result is URL('http://testserver/api/v1/this-works-as-expected'), 
```

the way that httpx performs this merge is via the following code (i've lifted it straight from the httpx source code):

```python
def _merge_url(self, url: URLTypes) -> URL:
    """
    Merge a URL argument together with any 'base_url' on the client,
    to create the URL used for the outgoing request.
    """
    merge_url = URL(url)
    if merge_url.is_relative_url:
        # To merge URLs we always append to the base URL. To get this
        # behaviour correct we always ensure the base URL ends in a '/'
        # separator, and strip any leading '/' from the merge URL.
        #
        # So, eg...
        #
        # >>> client = Client(base_url="https://www.example.com/subpath")
        # >>> client.base_url
        # URL('https://www.example.com/subpath/')
        # >>> client.build_request("GET", "/path").url
        # URL('https://www.example.com/subpath/path')
        merge_raw_path = self.base_url.raw_path + merge_url.raw_path.lstrip(b"/")
        return self.base_url.copy_with(raw_path=merge_raw_path)
    return merge_url
```

Starlette should be doing this the same way.
</div>

<!-- POLAR PLEDGE BADGE START -->
> [!IMPORTANT]
> - We're using [Polar.sh](https://polar.sh/encode) so you can upvote and help fund this issue.
> - We receive the funding once the issue is completed & confirmed by you.
> - Thank you in advance for helping prioritize & fund our backlog.

<a href="https://polar.sh/encode/starlette/issues/2306">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/encode/starlette/issues/2306/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/encode/starlette/issues/2306/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:

Resolving patch:
diff --git a/starlette/testclient.py b/starlette/testclient.py
--- a/starlette/testclient.py
+++ b/starlette/testclient.py
@@ -463,7 +463,7 @@ def request(  # type: ignore[override]
         ] = httpx._client.USE_CLIENT_DEFAULT,
         extensions: typing.Optional[typing.Dict[str, typing.Any]] = None,
     ) -> httpx.Response:
-        url = self.base_url.join(url)
+        url = self._merge_url(url)
         redirect = self._choose_redirect_arg(follow_redirects, allow_redirects)
         return super().request(
             method,


Test patch:
diff --git a/tests/middleware/test_session.py b/tests/middleware/test_session.py
--- a/tests/middleware/test_session.py
+++ b/tests/middleware/test_session.py
@@ -130,7 +130,7 @@ def test_session_cookie_subpath(test_client_factory):
     )
     app = Starlette(routes=[Mount("/second_app", app=second_app)])
     client = test_client_factory(app, base_url="http://testserver/second_app")
-    response = client.post("/second_app/update_session", json={"some": "data"})
+    response = client.post("/update_session", json={"some": "data"})
     assert response.status_code == 200
     cookie = response.headers["set-cookie"]
     cookie_path_match = re.search(r"; path=(\S+);", cookie)
diff --git a/tests/test_testclient.py b/tests/test_testclient.py
--- a/tests/test_testclient.py
+++ b/tests/test_testclient.py
@@ -354,3 +354,13 @@ def homepage(request: Request) -> JSONResponse:
     client = test_client_factory(app)
     response = client.get("/", headers=[("x-token", "foo"), ("x-token", "bar")])
     assert response.json() == {"x-token": ["foo", "bar"]}
+
+
+def test_merge_url(test_client_factory: Callable[..., TestClient]):
+    def homepage(request: Request) -> Response:
+        return Response(request.url.path)
+
+    app = Starlette(routes=[Route("/api/v1/bar", endpoint=homepage)])
+    client = test_client_factory(app, base_url="http://testserver/api/v1/")
+    response = client.get("/bar")
+    assert response.text == "/api/v1/bar"