Skip to content

Commit d51c43e

Browse files
committed
Add request-builder fixtures (#40)
Introduces `request_factory`, `manager_request`, and `anon_request`, replacing the 5+ near-identical session fixtures that downstream codebases reimplement. `request_factory` returns a built-in `RelativeSession` (a thin `requests.Session` subclass) bound to the functional portal. Supported modes: `role="Manager"`, `role="Anonymous"` (default), or explicit `basic_auth=(user, password)`. The `++api++` suffix is added by default. The standalone `RelativeSession` avoids pulling in `plone.restapi[test]`'s fragile import chain (`plone.app.iterate`, `collective.MockMailHost`) while keeping the same calling convention downstream code already expects. Closes #40
1 parent 29fb844 commit d51c43e

8 files changed

Lines changed: 348 additions & 0 deletions

File tree

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,61 @@ def test_manager_action(portal, grant_roles):
444444
# test user now has Manager role on portal
445445
```
446446

447+
### request_factory
448+
449+
| | |
450+
| --- | --- |
451+
| Description | Callable that builds a `RelativeSession` against the functional portal. |
452+
| Required Fixture | **functional_portal** |
453+
| Scope | **Function** |
454+
455+
Replaces the 5+ near-identical request-session fixtures that downstream codebases reimplement. Returns a `RelativeSession` (a thin `requests.Session` subclass that resolves relative URLs against the portal's base URL) with sensible defaults:
456+
457+
- `role="Manager"` — authenticate as the portal owner.
458+
- `role="Anonymous"` (default) — no authentication.
459+
- `basic_auth=(user, password)` — any other identity; takes precedence over `role`.
460+
- `api=True` (default) — suffix the base URL with `++api++` so relative calls hit the REST API.
461+
462+
Sessions are closed automatically at the end of the test.
463+
464+
```python
465+
def test_list_content(request_factory):
466+
"""Test that the Manager role can list content."""
467+
session = request_factory(role="Manager")
468+
response = session.get("/")
469+
assert response.status_code == 200
470+
```
471+
472+
### manager_request
473+
474+
| | |
475+
| --- | --- |
476+
| Description | `RelativeSession` pre-authenticated as the portal owner (Manager). |
477+
| Required Fixture | **request_factory** |
478+
| Scope | **Function** |
479+
480+
```python
481+
def test_controlpanels(manager_request):
482+
"""Test listing of control panels."""
483+
response = manager_request.get("/@controlpanels")
484+
assert response.status_code == 200
485+
```
486+
487+
### anon_request
488+
489+
| | |
490+
| --- | --- |
491+
| Description | `RelativeSession` with no authentication (Anonymous). |
492+
| Required Fixture | **request_factory** |
493+
| Scope | **Function** |
494+
495+
```python
496+
def test_public_endpoint(anon_request):
497+
"""Test a public REST API endpoint."""
498+
response = anon_request.get("/")
499+
assert response.status_code == 200
500+
```
501+
447502
## Markers
448503

449504
### @pytest.mark.portal

news/40.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `request_factory`, `manager_request`, and `anon_request` fixtures — build a `RelativeSession` against the functional portal with Manager, Anonymous, or custom basic-auth credentials. Replaces boilerplate duplicated across downstream codebases. @ericof

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ dependencies = [
4040
"plone.browserlayer",
4141
"plone.dexterity",
4242
"Products.CMFPlone[test]",
43+
"requests",
4344
"zope.component",
4445
"zope.schema",
4546
]

src/pytest_plone/_types.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from plone.dexterity.content import DexterityContent
55
from plone.dexterity.fti import DexterityFTI
66
from Products.CMFPlone.Portal import PloneSite
7+
from typing import Any
78
from typing import Protocol
89
from typing import TypeAlias
910
from zope.schema.vocabulary import SimpleVocabulary
@@ -45,3 +46,13 @@ def __call__(
4546

4647
class RolesGranter(Protocol):
4748
def __call__(self, context: DexterityContent | Item, roles: list[str]) -> None: ...
49+
50+
51+
class RequestFactory(Protocol):
52+
def __call__(
53+
self,
54+
*,
55+
role: str = ...,
56+
basic_auth: tuple[str, str] | None = ...,
57+
api: bool = ...,
58+
) -> Any: ...

src/pytest_plone/fixtures/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@
1717
from .content import get_behaviors
1818
from .content import get_fti
1919
from .env import generate_mo
20+
from .requests import anon_request
21+
from .requests import manager_request
22+
from .requests import request_factory
2023
from .security import grant_roles
2124
from .vocabularies import get_vocabulary
2225

2326
import pytest
2427

2528

2629
__all__ = [
30+
"anon_request",
2731
"app",
2832
"apply_profiles",
2933
"browser_layers",
@@ -39,8 +43,10 @@
3943
"grant_roles",
4044
"http_request",
4145
"installer",
46+
"manager_request",
4247
"portal",
4348
"profile_last_version",
49+
"request_factory",
4450
"setup_tool",
4551
"uninstalled",
4652
]
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Request-builder fixtures for REST API / functional HTTP tests."""
2+
3+
from plone.app.testing import SITE_OWNER_NAME
4+
from plone.app.testing import SITE_OWNER_PASSWORD
5+
from Products.CMFPlone.Portal import PloneSite
6+
from pytest_plone import _types as t
7+
from urllib.parse import urljoin
8+
from urllib.parse import urlparse
9+
10+
import pytest
11+
import requests
12+
13+
14+
_ROLE_AUTH: dict[str, tuple[str, str] | None] = {
15+
"Manager": (SITE_OWNER_NAME, SITE_OWNER_PASSWORD),
16+
"Anonymous": None,
17+
}
18+
19+
20+
class RelativeSession(requests.Session):
21+
"""`requests.Session` that resolves relative URLs against a base URL.
22+
23+
Minimal standalone equivalent of ``plone.restapi.testing.RelativeSession``
24+
— avoids pulling the full ``plone.restapi[test]`` import chain into
25+
pytest-plone's runtime.
26+
"""
27+
28+
def __init__(self, base_url: str) -> None:
29+
super().__init__()
30+
if not base_url.endswith("/"):
31+
base_url += "/"
32+
self._base_url = base_url
33+
34+
def request(self, method: str, url: str, **kwargs): # type: ignore[override]
35+
if urlparse(url).scheme not in ("http", "https"):
36+
url = urljoin(self._base_url, url.lstrip("/"))
37+
return super().request(method, url, **kwargs)
38+
39+
40+
@pytest.fixture
41+
def request_factory(
42+
functional_portal: PloneSite, request: pytest.FixtureRequest
43+
) -> t.RequestFactory:
44+
"""Builder fixture for HTTP request sessions against the functional portal.
45+
46+
Returns a callable that produces a :class:`RelativeSession` bound to the
47+
portal URL. The session is closed automatically at the end of the test.
48+
49+
Parameters accepted by the returned callable:
50+
51+
- ``role`` — ``"Manager"`` or ``"Anonymous"`` (default). Maps to
52+
predefined test credentials. Unknown roles raise ``ValueError`` — use
53+
``basic_auth`` for other identities.
54+
- ``basic_auth`` — ``(username, password)`` tuple; takes precedence over
55+
``role`` when provided.
56+
- ``api`` — when ``True`` (default), the base URL is suffixed with
57+
``++api++`` so relative requests hit the REST API traverser.
58+
59+
Example usage:
60+
```python
61+
def test_list_content(request_factory):
62+
session = request_factory(role="Manager")
63+
response = session.get("/")
64+
assert response.status_code == 200
65+
```
66+
"""
67+
68+
def factory(
69+
*,
70+
role: str = "Anonymous",
71+
basic_auth: tuple[str, str] | None = None,
72+
api: bool = True,
73+
) -> RelativeSession:
74+
base_url = functional_portal.absolute_url()
75+
if api:
76+
base_url = f"{base_url}/++api++"
77+
session = RelativeSession(base_url)
78+
session.headers.update({"Accept": "application/json"})
79+
if basic_auth is not None:
80+
session.auth = basic_auth
81+
elif role in _ROLE_AUTH:
82+
auth = _ROLE_AUTH[role]
83+
if auth is not None:
84+
session.auth = auth
85+
else:
86+
raise ValueError(
87+
f"Unknown role {role!r}. Pass role='Manager' or 'Anonymous', "
88+
"or use basic_auth=(username, password) for other identities."
89+
)
90+
request.addfinalizer(session.close)
91+
return session
92+
93+
return factory
94+
95+
96+
@pytest.fixture
97+
def manager_request(request_factory: t.RequestFactory) -> RelativeSession:
98+
"""A `RelativeSession` authenticated as the portal owner (Manager).
99+
100+
Example usage:
101+
```python
102+
def test_admin_endpoint(manager_request):
103+
response = manager_request.get("/@controlpanels")
104+
assert response.status_code == 200
105+
```
106+
"""
107+
return request_factory(role="Manager")
108+
109+
110+
@pytest.fixture
111+
def anon_request(request_factory: t.RequestFactory) -> RelativeSession:
112+
"""A `RelativeSession` with no authentication (Anonymous).
113+
114+
Example usage:
115+
```python
116+
def test_public_endpoint(anon_request):
117+
response = anon_request.get("/")
118+
assert response.status_code == 200
119+
```
120+
"""
121+
return request_factory(role="Anonymous")

tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def testdir(pytester: Pytester) -> Pytester:
3333

3434

3535
OUR_FIXTURES = [
36+
"anon_request",
3637
"app",
3738
"apply_profiles",
3839
"browser_layers",
@@ -48,8 +49,10 @@ def testdir(pytester: Pytester) -> Pytester:
4849
"grant_roles",
4950
"http_request",
5051
"installer",
52+
"manager_request",
5153
"portal",
5254
"profile_last_version",
55+
"request_factory",
5356
"setup_tool",
5457
]
5558

0 commit comments

Comments
 (0)