Skip to content

Commit 25ca942

Browse files
Add pytest suite for the FastAPI collection/item endpoints
+ pyproject.toml: set DJANGO_SETTINGS_MODULE for pytest-django + dev deps: add pytest-django (requirements.in/development.txt + regenerated requirements-dev.txt)
1 parent 9ca7de7 commit 25ca942

9 files changed

Lines changed: 363 additions & 1 deletion

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/journal
22
/db.sqlite3*
33
Session.vim
4-
/.venv
4+
/.venv*
55
/assets
66
/logs
77
/.coverage
@@ -10,6 +10,8 @@ Session.vim
1010
/.idea
1111

1212
__pycache__
13+
.pytest_cache/
14+
.ruff_cache/
1315
.*.swp
1416

1517
/etebase_server_settings.py
@@ -18,3 +20,4 @@ __pycache__
1820
/build
1921
/dist
2022
/*.egg-info
23+
.DS_Store

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,9 @@ ignore = ["E203", "E501", "E711", "E712", "N803", "N815", "N818", "T201"]
2626

2727
[tool.ruff.lint.isort]
2828
combine-as-imports = true
29+
30+
[tool.pytest.ini_options]
31+
testpaths = ["tests"]
32+
pythonpath = ["."]
33+
addopts = "-ra"
34+
DJANGO_SETTINGS_MODULE = "etebase_server.settings"

requirements-dev.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,12 @@ pyproject-hooks==1.2.0 \
344344
pytest==9.1.1 \
345345
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
346346
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
347+
# via
348+
# -r requirements.in/development.txt
349+
# pytest-django
350+
pytest-django==4.12.0 \
351+
--hash=sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85 \
352+
--hash=sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758
347353
# via -r requirements.in/development.txt
348354
pywatchman==4.0.0 \
349355
--hash=sha256:09229f15ccf0fb5ed15592d41530ef69011866e3da4bf72785a965099519a33b \

requirements.in/development.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
coverage
55
pip-tools
66
pytest
7+
pytest-django
78
pywatchman
89
ruff
910
mypy

tests/conftest.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Shared fixtures for the FastAPI endpoint tests.
2+
3+
Provides:
4+
- ``app`` : the real ASGI app built by ``create_application`` (session-scoped).
5+
- ``api_request`` : a synchronous ASGI driver to issue msgpack requests without httpx.
6+
- ``user`` / ``auth_token`` : user and token created via the ORM (they need a transactional
7+
DB, because the sync endpoints run in the threadpool and must see committed data).
8+
"""
9+
10+
import asyncio
11+
import os
12+
13+
import msgpack
14+
import pytest
15+
from django.conf import settings
16+
17+
from etebase_server.fastapi.utils import msgpack_encode
18+
19+
20+
@pytest.fixture(scope="session")
21+
def app():
22+
# create_application mounts StaticFiles on STATIC_ROOT and installs TrustedHostMiddleware:
23+
# in tests the dir does not exist and ALLOWED_HOSTS is empty, so we set them before building.
24+
settings.ALLOWED_HOSTS = ["*"]
25+
os.makedirs(settings.STATIC_ROOT, exist_ok=True)
26+
27+
from etebase_server.fastapi.main import create_application
28+
29+
return create_application()
30+
31+
32+
@pytest.fixture
33+
def api_request(app):
34+
"""Drive a single HTTP request through the ASGI app and return ``(status, decoded_body)``.
35+
36+
The request body is msgpack-encoded; the response is msgpack-decoded (falling back to raw
37+
bytes for non-msgpack responses, e.g. FastAPI's default 401/403).
38+
"""
39+
40+
def _request(method, path, *, body=None, token=None):
41+
raw = msgpack_encode(body) if body is not None else b""
42+
url_path, _, query = path.partition("?")
43+
44+
headers = [(b"host", b"testserver"), (b"accept", b"application/msgpack")]
45+
if body is not None:
46+
headers.append((b"content-type", b"application/msgpack"))
47+
if token is not None:
48+
headers.append((b"authorization", f"Token {token}".encode()))
49+
50+
scope = {
51+
"type": "http",
52+
"asgi": {"version": "3.0", "spec_version": "2.3"},
53+
"http_version": "1.1",
54+
"method": method,
55+
"path": url_path,
56+
"raw_path": url_path.encode(),
57+
"query_string": query.encode(),
58+
"headers": headers,
59+
"scheme": "http",
60+
"server": ("testserver", 80),
61+
"client": ("testclient", 1),
62+
"root_path": "",
63+
}
64+
65+
async def receive():
66+
return {"type": "http.request", "body": raw, "more_body": False}
67+
68+
captured = {"status": None, "body": b""}
69+
70+
async def send(message):
71+
if message["type"] == "http.response.start":
72+
captured["status"] = message["status"]
73+
elif message["type"] == "http.response.body":
74+
captured["body"] += message.get("body", b"")
75+
76+
asyncio.run(app(scope, receive, send))
77+
78+
out = captured["body"]
79+
try:
80+
decoded = msgpack.unpackb(out, raw=False) if out else None
81+
except Exception:
82+
decoded = out
83+
return captured["status"], decoded
84+
85+
return _request
86+
87+
88+
@pytest.fixture
89+
def user(transactional_db):
90+
from etebase_server.myauth.models import get_typed_user_model
91+
92+
User = get_typed_user_model()
93+
return User.objects.create(username="testuser", email="testuser@example.com")
94+
95+
96+
@pytest.fixture
97+
def auth_token(user):
98+
from etebase_server.django.token_auth.models import AuthToken
99+
100+
return AuthToken.objects.create(user=user).key

tests/integration/__init__.py

Whitespace-only changes.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Integration smoke tests for the collection/item endpoints.
2+
3+
They drive real msgpack requests through the ASGI app (auth + dependency resolution + ORM).
4+
The key signal is **404 vs 422**: before the ``collection_uid`` fix the item endpoints returned
5+
422; with the fix, a nonexistent collection returns 404.
6+
"""
7+
8+
# Plausible but nonexistent uid: it must be resolved as a path param (404), not 422.
9+
NONEXISTENT = "0" * 64
10+
11+
12+
def test_item_list_nonexistent_collection_returns_404_not_422(api_request, auth_token):
13+
status, _ = api_request("GET", f"/api/v1/collection/{NONEXISTENT}/item/", token=auth_token)
14+
assert status == 404
15+
16+
17+
def test_item_batch_wellformed_body_returns_404_not_422(api_request, auth_token):
18+
body = {
19+
"items": [
20+
{
21+
"uid": "item-uid",
22+
"version": 1,
23+
"encryptionKey": b"key",
24+
"content": {
25+
"uid": "item-uid",
26+
"meta": b"meta",
27+
"deleted": False,
28+
"chunks": [["chunk-uid", b"data"]],
29+
},
30+
"etag": None,
31+
}
32+
],
33+
"deps": None,
34+
}
35+
status, _ = api_request(
36+
"POST", f"/api/v1/collection/{NONEXISTENT}/item/batch/", body=body, token=auth_token
37+
)
38+
assert status == 404
39+
40+
41+
def test_list_multi_returns_200(api_request, auth_token):
42+
status, decoded = api_request(
43+
"POST", "/api/v1/collection/list_multi/", body={"collectionTypes": []}, token=auth_token
44+
)
45+
assert status == 200
46+
assert decoded["data"] == []
47+
48+
49+
def test_item_list_without_token_returns_401(api_request):
50+
# Missing Authorization header -> APIKeyHeader (auto_error) responds 401.
51+
status, _ = api_request("GET", f"/api/v1/collection/{NONEXISTENT}/item/")
52+
assert status == 401
53+
54+
55+
def test_member_list_nonexistent_collection_returns_404_not_422(api_request, auth_token):
56+
# member_router is also mounted under /collection/{collection_uid}: same 422 risk,
57+
# covered by the same get_collection fix.
58+
status, _ = api_request("GET", f"/api/v1/collection/{NONEXISTENT}/member/", token=auth_token)
59+
assert status == 404

tests/unit/test_exceptions.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Pure unit tests for ``etebase_server.fastapi.exceptions``.
2+
3+
They cover the HTTP exception hierarchy and the error-transformation helpers. These are
4+
"classic" unit tests: they need no database, no configured Django settings, and no running
5+
server. The only requirement is that ``django.core.exceptions``, ``fastapi`` and ``pydantic``
6+
are importable.
7+
"""
8+
9+
import pytest
10+
from django.core.exceptions import ValidationError as DjangoValidationError
11+
from fastapi import HTTPException, status
12+
13+
from etebase_server.fastapi.exceptions import (
14+
AuthenticationFailed,
15+
CustomHttpException,
16+
HttpError,
17+
NotAuthenticated,
18+
NotSupported,
19+
PermissionDenied,
20+
ValidationError,
21+
flatten_errors,
22+
transform_validation_error,
23+
)
24+
25+
26+
class TestCustomHttpException:
27+
def test_defaults(self):
28+
exc = CustomHttpException(code="boom", detail="Something broke")
29+
assert exc.code == "boom"
30+
assert exc.detail == "Something broke"
31+
assert exc.status_code == status.HTTP_400_BAD_REQUEST
32+
assert isinstance(exc, HTTPException)
33+
34+
def test_custom_status_code(self):
35+
exc = CustomHttpException(code="teapot", detail="no coffee", status_code=status.HTTP_418_IM_A_TEAPOT)
36+
assert exc.status_code == status.HTTP_418_IM_A_TEAPOT
37+
38+
def test_as_dict(self):
39+
exc = CustomHttpException(code="boom", detail="broke")
40+
assert exc.as_dict == {"code": "boom", "detail": "broke"}
41+
42+
43+
@pytest.mark.parametrize(
44+
"exc_cls, expected_code, expected_status",
45+
[
46+
(AuthenticationFailed, "authentication_failed", status.HTTP_401_UNAUTHORIZED),
47+
(NotAuthenticated, "not_authenticated", status.HTTP_401_UNAUTHORIZED),
48+
(PermissionDenied, "permission_denied", status.HTTP_403_FORBIDDEN),
49+
(NotSupported, "not_implemented", status.HTTP_501_NOT_IMPLEMENTED),
50+
],
51+
)
52+
def test_subclass_defaults(exc_cls, expected_code, expected_status):
53+
exc = exc_cls()
54+
assert exc.code == expected_code
55+
assert exc.status_code == expected_status
56+
assert isinstance(exc, CustomHttpException)
57+
58+
59+
class TestHttpError:
60+
def test_defaults(self):
61+
exc = HttpError(code="bad", detail="nope")
62+
assert exc.code == "bad"
63+
assert exc.status_code == status.HTTP_400_BAD_REQUEST
64+
assert exc.errors is None
65+
66+
def test_empty_code_falls_back_to_generic(self):
67+
exc = HttpError(code="", detail="nope")
68+
assert exc.code == "generic_error"
69+
70+
def test_as_dict_without_errors(self):
71+
exc = HttpError(code="bad", detail="nope")
72+
assert exc.as_dict == {"code": "bad", "detail": "nope", "errors": None}
73+
74+
def test_as_dict_serializes_nested_validation_errors(self):
75+
nested = ValidationError(code="invalid", detail="too short", field="password")
76+
exc = HttpError(code="field_errors", detail="Field validations failed.", errors=[nested])
77+
result = exc.as_dict
78+
assert result["code"] == "field_errors"
79+
assert result["detail"] == "Field validations failed."
80+
assert result["errors"] == [{"field": "password", "code": "invalid", "detail": "too short"}]
81+
82+
83+
class TestValidationError:
84+
def test_field_is_stored(self):
85+
exc = ValidationError(code="invalid", detail="bad value", field="email")
86+
assert exc.field == "email"
87+
assert exc.code == "invalid"
88+
assert isinstance(exc, HttpError)
89+
90+
91+
class TestTransformValidationError:
92+
def test_dict_errors_become_field_errors(self):
93+
django_err = DjangoValidationError({"name": ["This field is required."]})
94+
with pytest.raises(HttpError) as exc_info:
95+
transform_validation_error("user", django_err)
96+
97+
exc = exc_info.value
98+
assert exc.code == "field_errors"
99+
assert exc.detail == "Field validations failed."
100+
assert len(exc.errors) == 1
101+
only = exc.errors[0]
102+
assert isinstance(only, ValidationError)
103+
assert only.field == "user.name"
104+
assert only.detail == "This field is required."
105+
106+
def test_list_errors_become_field_errors(self):
107+
django_err = DjangoValidationError(["first problem", "second problem"])
108+
with pytest.raises(HttpError) as exc_info:
109+
transform_validation_error("user", django_err)
110+
111+
exc = exc_info.value
112+
assert exc.code == "field_errors"
113+
assert len(exc.errors) == 2
114+
115+
def test_single_message_raises_plain_http_error(self):
116+
django_err = DjangoValidationError("just one message", code="custom_code")
117+
with pytest.raises(HttpError) as exc_info:
118+
transform_validation_error("user", django_err)
119+
120+
exc = exc_info.value
121+
assert exc.code == "custom_code"
122+
assert exc.detail == "just one message"
123+
124+
125+
class TestFlattenErrors:
126+
def test_flattens_list_of_django_errors(self):
127+
django_err = DjangoValidationError(["problem a", "problem b"])
128+
result = flatten_errors("field", django_err.error_list)
129+
assert len(result) == 2
130+
assert all(isinstance(e, ValidationError) for e in result)
131+
assert all(e.field == "field" for e in result)
132+
assert {e.detail for e in result} == {"problem a", "problem b"}
133+
134+
def test_flattens_nested_dict_of_django_errors(self):
135+
django_err = DjangoValidationError({"name": ["required"], "email": ["invalid"]})
136+
result = flatten_errors("user", django_err.error_dict)
137+
assert len(result) == 2
138+
fields = {e.field for e in result}
139+
assert fields == {"user.name", "user.email"}

0 commit comments

Comments
 (0)