diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf94e18..df4f62a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,7 +34,7 @@ repos: args: [ "--ignore-words-list", - "ommit", + "ommit,asend", ] - repo: https://github.com/pre-commit/mirrors-mypy rev: "v1.14.1" diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml index 75bf8f9..99af500 100644 --- a/packages/cli/pyproject.toml +++ b/packages/cli/pyproject.toml @@ -65,9 +65,10 @@ lint.flake8-comprehensions.allow-dict-calls-with-keyword-arguments = true lint.per-file-ignores."tests/**" = ["PLR2004", "PLR0913"] lint.per-file-ignores."tests/workerd-test/**" = ["C901", "PLR0911", "PLR0912", "PLR2004", "PLW0603"] lint.per-file-ignores."tests/bindings-test/**" = ["C901", "PLR0911", "PLR0912", "PLR2004", "PLW0603"] +lint.per-file-ignores."tests/web-frameworks-test/**" = ["C901", "PLR0911", "PLR0912", "PLR2004", "PLW0603"] [tool.pytest.ini_options] -addopts = ["--ignore=tests/workerd-test", "--ignore=tests/bindings-test"] +addopts = ["--ignore=tests/workerd-test", "--ignore=tests/bindings-test", "--ignore=tests/web-frameworks-test"] [tool.mypy] packages = ["pywrangler"] diff --git a/packages/cli/tests/conftest.py b/packages/cli/tests/conftest.py index 26ccbe7..48bdd25 100644 --- a/packages/cli/tests/conftest.py +++ b/packages/cli/tests/conftest.py @@ -3,10 +3,193 @@ # - "2025-09-01" -> Python 3.12 (before the 2025-09-29 cutover) # - "2026-01-01" -> Python 3.13 (after the 2025-09-29 cutover) # TODO: use compat flag instead of compat date + +import ast +import os +import shutil +import socket +import subprocess +import time +from collections.abc import Callable, Generator from pathlib import Path +from typing import Any, Literal, TypedDict + +import pytest +import requests COMPAT_DATES: list[str] = ["2025-09-01", "2026-01-01"] +TEST_DIR: Path = Path(__file__).parent +WORKERS_PY: Path = TEST_DIR.parent +WORKERS_RUNTIME_SDK: Path = WORKERS_PY.parent / "runtime-sdk" / "src" + +DEV_STARTUP_TIMEOUT: int = 120 +DEV_POLL_INTERVAL: float = 0.5 + + +class InWorkerTestResult(TypedDict): + status: Literal["passed", "failed", "error", "skipped"] + error: str + traceback: str + reason: str + + +SuiteResults = dict[str, InWorkerTestResult] + def replace_compat_date(file: Path, compat_date: str) -> None: file.write_text(file.read_text().replace("%COMPAT_DATE", compat_date)) + + +def get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def wait_for_ready(process: subprocess.Popen[str], base_url: str) -> None: + """Poll the /health endpoint until the dev server is accepting requests.""" + deadline = time.time() + DEV_STARTUP_TIMEOUT + while time.time() < deadline: + if process.poll() is not None: + stdout = process.stdout.read() if process.stdout else "" + pytest.fail( + f"pywrangler dev exited early with code {process.returncode}\n" + f"stdout: {stdout}" + ) + try: + resp = requests.get(f"{base_url}/health", timeout=2) + if resp.ok: + return + except (requests.ConnectionError, requests.Timeout): + time.sleep(DEV_POLL_INTERVAL) + + process.kill() + process.wait() + pytest.fail(f"pywrangler dev did not become ready within {DEV_STARTUP_TIMEOUT}s") + + +def start_dev_server( + test_project_dir: Path, + tmp_path_factory: pytest.TempPathFactory, + compat_date: str, + tmp_dir_prefix: str = "dev_server", +) -> Generator[str]: + """Copy a test project, sync packages, start pywrangler dev, yield base URL.""" + tmp_path = tmp_path_factory.mktemp(tmp_dir_prefix) + target = tmp_path / test_project_dir.name + shutil.copytree(test_project_dir, target) + env = os.environ | {"_PYODIDE_EXTRA_MOUNTS": str(tmp_path)} + + replace_compat_date(target / "wrangler.jsonc", compat_date) + + subprocess.run( + ["uv", "run", "--with", WORKERS_PY, "pywrangler", "sync"], + cwd=target, + check=True, + env=env, + ) + + shutil.copytree(WORKERS_RUNTIME_SDK, target / "python_modules", dirs_exist_ok=True) + + port: int = get_free_port() + base_url: str = f"http://127.0.0.1:{port}" + + process = subprocess.Popen( + [ + "uv", + "run", + "--with", + WORKERS_PY, + "pywrangler", + "dev", + "--port", + str(port), + "--persist-to", + str(tmp_path / "state"), + ], + cwd=target, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env, + ) + + wait_for_ready(process, base_url) + yield base_url + + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +_suite_cache: dict[tuple[str, str], "SuiteResults | str"] = {} + + +def get_test_results(dev_server: str, suite: str) -> SuiteResults: + key = (dev_server, suite) + if key not in _suite_cache: + try: + resp = requests.get(f"{dev_server}/run-tests/{suite}", timeout=60) + _suite_cache[key] = ( + resp.json() + if resp.ok + else f"Suite '{suite}' returned {resp.status_code}: {resp.text}" + ) + except (requests.ConnectionError, requests.Timeout) as e: + _suite_cache[key] = f"Suite '{suite}' request failed: {e}" + cached = _suite_cache[key] + if isinstance(cached, str): + pytest.fail(cached) + return cached + + +def make_test(suite: str, test_name: str) -> Callable: + def test_fn(self: Any, dev_server: str) -> None: + results = get_test_results(dev_server, suite) + result: InWorkerTestResult | None = results.get(test_name) + assert result is not None, f"Test {suite}::{test_name} not found in results" + if result["status"] == "skipped": + pytest.skip(result.get("reason", "")) + elif result["status"] == "failed": + pytest.fail(result["error"]) + elif result["status"] == "error": + pytest.fail(f"{result['error']}\n{result.get('traceback', '')}") + + test_fn.__name__ = f"test_{test_name}" + return test_fn + + +def suite_class(suite: str, tests: list[str]) -> type: + """Create a test class with one method per in-worker test.""" + return type( + f"Test{suite.upper()}", + (), + {f"test_{name}": make_test(suite, name) for name in tests}, + ) + + +def discover_test_names(module_path: Path) -> list[str]: + """Return the suite-relative names of test functions defined in a module. + + Parses the source statically (no import) and strips the ``test_`` prefix so + the names match the keys returned by the in-worker ResultCollector. + """ + tree = ast.parse(module_path.read_text()) + return [ + node.name[len("test_") :] + for node in tree.body + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) + and node.name.startswith("test_") + ] + + +def discover_suites(src_dir: Path) -> dict[str, list[str]]: + """Map each ``test_.py`` module to its discovered test names.""" + return { + module_path.stem[len("test_") :]: discover_test_names(module_path) + for module_path in sorted(src_dir.glob("test_*.py")) + } diff --git a/packages/cli/tests/test_bindings.py b/packages/cli/tests/test_bindings.py index 9b163d2..819a572 100644 --- a/packages/cli/tests/test_bindings.py +++ b/packages/cli/tests/test_bindings.py @@ -11,67 +11,20 @@ and add any required binding to wrangler.jsonc. """ -import ast -import functools -import os -import shutil -import socket -import subprocess -import time -from collections.abc import Callable, Generator +from collections.abc import Generator from pathlib import Path -from typing import Any, Literal, TypedDict import pytest -import requests -from conftest import COMPAT_DATES, replace_compat_date +from conftest import ( + COMPAT_DATES, + TEST_DIR, + discover_suites, + start_dev_server, + suite_class, +) -TEST_DIR: Path = Path(__file__).parent BINDINGS_TEST_DIR: Path = TEST_DIR / "bindings-test" BINDINGS_SRC_DIR: Path = BINDINGS_TEST_DIR / "src" -WORKERS_PY: Path = TEST_DIR.parent -WORKERS_RUNTIME_SDK: Path = WORKERS_PY.parent / "runtime-sdk" / "src" - -DEV_STARTUP_TIMEOUT: int = 120 -DEV_POLL_INTERVAL: float = 0.5 - - -class BindingTestResult(TypedDict): - status: Literal["passed", "failed", "error", "skipped"] - error: str - traceback: str - reason: str - - -SuiteResults = dict[str, BindingTestResult] - - -def _get_free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -def _wait_for_ready(process: subprocess.Popen[str], base_url: str) -> None: - """Poll the /health endpoint until the dev server is accepting requests.""" - deadline = time.time() + DEV_STARTUP_TIMEOUT - while time.time() < deadline: - if process.poll() is not None: - stdout = process.stdout.read() if process.stdout else "" - pytest.fail( - f"pywrangler dev exited early with code {process.returncode}\n" - f"stdout: {stdout}" - ) - try: - resp = requests.get(f"{base_url}/health", timeout=2) - if resp.ok: - return - except (requests.ConnectionError, requests.Timeout): - time.sleep(DEV_POLL_INTERVAL) - - process.kill() - process.wait() - pytest.fail(f"pywrangler dev did not become ready within {DEV_STARTUP_TIMEOUT}s") @pytest.fixture(scope="module", params=COMPAT_DATES) @@ -83,114 +36,13 @@ def compat_date(request: pytest.FixtureRequest) -> str: def dev_server( tmp_path_factory: pytest.TempPathFactory, compat_date: str ) -> Generator[str]: - """Start a pywrangler dev server on a free port and yield its base URL.""" - tmp_path = tmp_path_factory.mktemp("bindings_test") - target = tmp_path / "bindings-test" - shutil.copytree(BINDINGS_TEST_DIR, target) - env = os.environ | {"_PYODIDE_EXTRA_MOUNTS": str(tmp_path)} - - replace_compat_date(target / "wrangler.jsonc", compat_date) - - subprocess.run( - ["uv", "run", "--with", WORKERS_PY, "pywrangler", "sync"], - cwd=target, - check=True, - env=env, - ) - - shutil.copytree(WORKERS_RUNTIME_SDK, target / "python_modules", dirs_exist_ok=True) - - port: int = _get_free_port() - base_url: str = f"http://127.0.0.1:{port}" - - process = subprocess.Popen( - [ - "uv", - "run", - "--with", - WORKERS_PY, - "pywrangler", - "dev", - "--port", - str(port), - "--persist-to", - str(tmp_path / "state"), - ], - cwd=target, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=env, + yield from start_dev_server( + BINDINGS_TEST_DIR, tmp_path_factory, compat_date, "bindings_test" ) - _wait_for_ready(process, base_url) - yield base_url - - process.terminate() - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - - -@functools.cache -def _get_test_results(dev_server: str, suite: str) -> SuiteResults: - resp = requests.get(f"{dev_server}/run-tests/{suite}", timeout=60) - assert resp.ok, f"Suite '{suite}' returned {resp.status_code}: {resp.text}" - return resp.json() - - -def _make_test(suite: str, test_name: str) -> Callable: - def test_fn(self: Any, dev_server: str) -> None: - results = _get_test_results(dev_server, suite) - result: BindingTestResult | None = results.get(test_name) - assert result is not None, f"Test {suite}::{test_name} not found in results" - if result["status"] == "skipped": - pytest.skip(result.get("reason", "")) - elif result["status"] == "failed": - pytest.fail(result["error"]) - elif result["status"] == "error": - pytest.fail(f"{result['error']}\n{result.get('traceback', '')}") - - test_fn.__name__ = f"test_{test_name}" - return test_fn - - -def binding_suite(suite: str, tests: list[str]) -> type: - """Register a binding test suite: creates a test class with one method per test.""" - return type( - f"Test{suite.upper()}", - (), - {f"test_{name}": _make_test(suite, name) for name in tests}, - ) - - -def _discover_test_names(module_path: Path) -> list[str]: - """Return the suite-relative names of test functions defined in a module. - - Parses the source statically (no import) and strips the ``test_`` prefix so - the names match the keys returned by the in-worker ResultCollector. - """ - tree = ast.parse(module_path.read_text()) - return [ - node.name[len("test_") :] - for node in tree.body - if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) - and node.name.startswith("test_") - ] - - -def _discover_suites() -> dict[str, list[str]]: - """Map each ``test_.py`` module to its discovered test names.""" - return { - module_path.stem[len("test_") :]: _discover_test_names(module_path) - for module_path in sorted(BINDINGS_SRC_DIR.glob("test_*.py")) - } - # Generate a TestXxx class per discovered suite so each in-worker test surfaces # as its own pytest case without manual registration. -for _suite, _test_names in _discover_suites().items(): - _suite_cls = binding_suite(_suite, _test_names) +for _suite, _test_names in discover_suites(BINDINGS_SRC_DIR).items(): + _suite_cls = suite_class(_suite, _test_names) globals()[_suite_cls.__name__] = _suite_cls diff --git a/packages/cli/tests/test_web_frameworks.py b/packages/cli/tests/test_web_frameworks.py new file mode 100644 index 0000000..d55f6a0 --- /dev/null +++ b/packages/cli/tests/test_web_frameworks.py @@ -0,0 +1,48 @@ +"""Tests for web framework compatibility running against a live pywrangler dev server. + +To add a new framework: create a subdirectory under web-frameworks-test/ with +worker.py, wrangler.jsonc, pyproject.toml, and test_*.py files. + +Python 3.12 (Pyodide 0.26.0a2) is excluded. The in-worker pytest suite drives +async tests via ``loop.run_until_complete``, which is a no-op on Pyodide 0.26.0a2 +(no ``run_sync``/JSPI): async tests return unawaited futures and report false +passes. +""" + +from collections.abc import Generator +from pathlib import Path + +import pytest +from conftest import ( + COMPAT_DATES, + TEST_DIR, + discover_suites, + start_dev_server, + suite_class, +) + +WEB_FRAMEWORKS_DIR: Path = TEST_DIR / "web-frameworks-test" +DJANGO_ASYNC_DIR: Path = WEB_FRAMEWORKS_DIR / "django-async" +DJANGO_ASYNC_SRC_DIR: Path = DJANGO_ASYNC_DIR / "src" + + +ASYNC_COMPAT_DATES = [d for d in COMPAT_DATES if d != "2025-09-01"] + + +@pytest.fixture(scope="module", params=ASYNC_COMPAT_DATES) +def compat_date(request: pytest.FixtureRequest) -> str: + return request.param + + +@pytest.fixture(scope="module") +def dev_server( + tmp_path_factory: pytest.TempPathFactory, compat_date: str +) -> Generator[str]: + yield from start_dev_server( + DJANGO_ASYNC_DIR, tmp_path_factory, compat_date, "django_async_test" + ) + + +for _suite, _test_names in discover_suites(DJANGO_ASYNC_SRC_DIR).items(): + _suite_cls = suite_class(_suite, _test_names) + globals()[_suite_cls.__name__] = _suite_cls diff --git a/packages/cli/tests/web-frameworks-test/django-async/pyproject.toml b/packages/cli/tests/web-frameworks-test/django-async/pyproject.toml new file mode 100644 index 0000000..00320f7 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "django-async-test" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "django", + "djangorestframework", + "django-cors-headers", + "pytest", + "pytest-asyncio<1.2.0", +] diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/_client.py b/packages/cli/tests/web-frameworks-test/django-async/src/_client.py new file mode 100644 index 0000000..ad20523 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/_client.py @@ -0,0 +1,92 @@ +import json as _json + +import asgi +from workers import Request + +BASE_URL = "http://testserver" + + +# Django uses content-length header when handling request body. +# Ensure content-length header is set when body is provided. +# Note that this is required only for testing as we build requests manually. +# In real HTTP requests, the browser sets the content-length header automatically. +def _with_content_length(headers, body): + hdrs = dict(headers or {}) + if body is not None and not any(k.lower() == "content-length" for k in hdrs): + length = len(body.encode() if isinstance(body, str) else body) + hdrs["Content-Length"] = str(length) + return hdrs + + +async def fetch(app, path, method="GET", headers=None, body=None): + request = Request( + f"{BASE_URL}{path}", + method=method, + headers=_with_content_length(headers, body), + body=body, + ) + return await asgi.fetch(app, request, {}) + + +async def read_json(response): + text = await response.text() + return _json.loads(text) if text else None + + +async def get_json(app, path, **kwargs): + response = await fetch(app, path, **kwargs) + return response, await read_json(response) + + +async def post_json(app, path, data, headers=None, **kwargs): + return await fetch( + app, + path, + method="POST", + headers={"Content-Type": "application/json", **(headers or {})}, + body=_json.dumps(data), + **kwargs, + ) + + +async def post_form(app, path, body, headers=None, **kwargs): + return await fetch( + app, + path, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + **(headers or {}), + }, + body=body, + **kwargs, + ) + + +def build_multipart(files, boundary="----WebTestBoundary"): + lines = [] + for name, filename, content in files: + if isinstance(content, bytes): + content = content.decode() + lines.append(f"--{boundary}") + lines.append( + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"' + ) + lines.append("Content-Type: application/octet-stream") + lines.append("") + lines.append(content) + lines.append(f"--{boundary}--") + lines.append("") + body = "\r\n".join(lines) + content_type = f"multipart/form-data; boundary={boundary}" + return body, content_type + + +def cookie_value(header_value, name): + if not header_value: + return None + for part in header_value.split(","): + candidate = part.strip().split(";", 1)[0] + if candidate.startswith(f"{name}="): + return candidate.split("=", 1)[1] + return None diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/conftest.py b/packages/cli/tests/web-frameworks-test/django-async/src/conftest.py new file mode 100644 index 0000000..f18c4a6 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/conftest.py @@ -0,0 +1,9 @@ +# pyright: reportMissingImports=false + +import pytest +from workers import env as _env + + +@pytest.fixture +def env(): + return _env diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/__init__.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/api_urls.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/api_urls.py new file mode 100644 index 0000000..498c5e1 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/api_urls.py @@ -0,0 +1,13 @@ +from django.http import JsonResponse +from django.urls import path + +app_name = "api" + + +async def info(request): + return JsonResponse({"namespace": "api-v1"}) + + +urlpatterns = [ + path("info/", info, name="info"), +] diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/auth_backend.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/auth_backend.py new file mode 100644 index 0000000..ee254ea --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/auth_backend.py @@ -0,0 +1,77 @@ +import hashlib + +from django.contrib.auth.backends import BaseBackend + +USERS = { + "testuser": {"pk": 1, "password": "testpass123", "is_staff": False}, + "admin": {"pk": 2, "password": "adminpass123", "is_staff": True}, +} + +_USERNAME_BY_PK = {data["pk"]: username for username, data in USERS.items()} + + +class _PkField: + def value_to_string(self, obj): + return str(obj.pk) + + +class _Meta: + pk = _PkField() + + +class InMemoryUser: + _meta = _Meta() + last_login = None + + def __init__(self, username): + data = USERS[username] + self.username = username + self.pk = data["pk"] + self.id = data["pk"] + self.is_staff = data["is_staff"] + self.is_authenticated = True + self.is_anonymous = False + self.is_active = True + + def save(self, *args, **kwargs): + pass + + def __str__(self): + return self.username + + def get_username(self): + return self.username + + def get_session_auth_hash(self): + return hashlib.sha256(self.username.encode()).hexdigest() + + def has_perm(self, perm, obj=None): + return self.is_staff or perm == "can_view" + + async def ahas_perm(self, perm, obj=None): + return self.has_perm(perm, obj=obj) + + def has_perms(self, perm_list, obj=None): + return all(self.has_perm(perm, obj=obj) for perm in perm_list) + + async def ahas_perms(self, perm_list, obj=None): + return self.has_perms(perm_list, obj=obj) + + def has_module_perms(self, app_label): + return self.is_staff + + +class InMemoryBackend(BaseBackend): + def authenticate(self, request, username=None, password=None): + if username is None or password is None: + return None + data = USERS.get(username) + if data is None or data["password"] != password: + return None + return InMemoryUser(username) + + def get_user(self, user_id): + username = _USERNAME_BY_PK.get(user_id) + if username is None: + return None + return InMemoryUser(username) diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_auth.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_auth.py new file mode 100644 index 0000000..1787d7e --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_auth.py @@ -0,0 +1,24 @@ +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + +TOKENS = { + "test-token-123": "testuser", + "admin-token-456": "admin", +} + + +class TokenAuthentication(BaseAuthentication): + def authenticate_header(self, request): + return "Bearer" + + def authenticate(self, request): + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + if not auth_header.startswith("Bearer "): + return None + token = auth_header[7:] + username = TOKENS.get(token) + if username is None: + raise AuthenticationFailed("Invalid token") + from django_app.auth_backend import InMemoryUser + + return (InMemoryUser(username), token) diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_serializers.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_serializers.py new file mode 100644 index 0000000..97a967f --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_serializers.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + + +class ItemSerializer(serializers.Serializer): + name = serializers.CharField(max_length=100) + price = serializers.FloatField(min_value=0) + quantity = serializers.IntegerField(min_value=0) + tags = serializers.ListField( + child=serializers.CharField(), required=False, default=list + ) + + def validate_name(self, value): + if value == "invalid": + raise serializers.ValidationError("This name is not allowed") + return value + + def validate(self, data): + if data["price"] > 1000 and data["quantity"] > 100: + raise serializers.ValidationError("bulk order too large") + return data + + +class OrderItemSerializer(serializers.Serializer): + product = serializers.CharField() + quantity = serializers.IntegerField(min_value=1) + + +class OrderSerializer(serializers.Serializer): + customer_name = serializers.CharField(max_length=100) + items = OrderItemSerializer(many=True) + notes = serializers.CharField(required=False, default="") + + def validate_items(self, value): + if len(value) < 1: + raise serializers.ValidationError("Must include at least one item") + return value diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_throttling.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_throttling.py new file mode 100644 index 0000000..3856f5d --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_throttling.py @@ -0,0 +1,12 @@ +from rest_framework.throttling import SimpleRateThrottle + + +class TestAnonThrottle(SimpleRateThrottle): + scope = "test_anon" + rate = "3/min" + + def get_cache_key(self, request, view): + return self.cache_format % { + "scope": self.scope, + "ident": self.get_ident(request), + } diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_views.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_views.py new file mode 100644 index 0000000..d2a0a57 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/drf_views.py @@ -0,0 +1,134 @@ +from django.urls import path +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated +from rest_framework.response import Response as DRFResponse +from rest_framework.versioning import QueryParameterVersioning +from rest_framework.views import APIView + +from django_app.drf_auth import TokenAuthentication +from django_app.drf_throttling import TestAnonThrottle + + +class EchoAPIView(APIView): + def get(self, request): + return DRFResponse({"method": "GET", "query": request.query_params.dict()}) + + def post(self, request): + return DRFResponse( + {"method": "POST", "data": request.data}, status=status.HTTP_201_CREATED + ) + + def put(self, request): + return DRFResponse({"method": "PUT", "data": request.data}) + + def delete(self, request): + return DRFResponse({"method": "DELETE"}, status=status.HTTP_204_NO_CONTENT) + + +@api_view(["GET", "POST"]) +def function_api_view(request): + if request.method == "GET": + return DRFResponse({"message": "hello from function view"}) + return DRFResponse({"received": request.data}, status=status.HTTP_201_CREATED) + + +class SerializerTestView(APIView): + def post(self, request): + from django_app.drf_serializers import ItemSerializer + + serializer = ItemSerializer(data=request.data) + if serializer.is_valid(): + return DRFResponse({"valid": True, "data": serializer.validated_data}) + return DRFResponse( + {"valid": False, "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def get(self, request): + from django_app.drf_serializers import ItemSerializer + + data = {"name": "test-item", "price": 29.99, "quantity": 5, "tags": ["a", "b"]} + serializer = ItemSerializer(data) + return DRFResponse(serializer.data) + + +class NestedSerializerView(APIView): + def post(self, request): + from django_app.drf_serializers import OrderSerializer + + serializer = OrderSerializer(data=request.data) + if serializer.is_valid(): + return DRFResponse({"valid": True, "data": serializer.validated_data}) + return DRFResponse( + {"valid": False, "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class AuthenticatedView(APIView): + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + return DRFResponse({"user": str(request.user), "authenticated": True}) + + +class AllowAnyView(APIView): + permission_classes = [AllowAny] + + def get(self, request): + return DRFResponse({"public": True}) + + +class AdminOnlyView(APIView): + authentication_classes = [TokenAuthentication] + permission_classes = [IsAdminUser] + + def get(self, request): + return DRFResponse({"admin": True}) + + +class CustomAuthView(APIView): + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + return DRFResponse({"user": request.user.username, "auth": "token"}) + + +class ThrottledView(APIView): + throttle_classes = [TestAnonThrottle] + + def get(self, request): + return DRFResponse({"throttled": False}) + + +class VersionedView(APIView): + versioning_class = QueryParameterVersioning + + def get(self, request): + return DRFResponse({"version": request.version or "default"}) + + +class ContentNegotiationView(APIView): + def post(self, request): + data = request.data + if hasattr(data, "dict"): + data = data.dict() + return DRFResponse({"content_type": request.content_type, "data": data}) + + +urlpatterns = [ + path("api-view/", EchoAPIView.as_view(), name="drf-api-view"), + path("function-view/", function_api_view, name="drf-function-view"), + path("serializer/", SerializerTestView.as_view(), name="drf-serializer"), + path("nested/", NestedSerializerView.as_view(), name="drf-nested"), + path("auth/", AuthenticatedView.as_view(), name="drf-authenticated"), + path("public/", AllowAnyView.as_view(), name="drf-public"), + path("admin/", AdminOnlyView.as_view(), name="drf-admin"), + path("custom-auth/", CustomAuthView.as_view(), name="drf-custom-auth"), + path("versioned/", VersionedView.as_view(), name="drf-versioned"), + path("throttled/", ThrottledView.as_view(), name="drf-throttled"), + path("content/", ContentNegotiationView.as_view(), name="drf-content"), +] diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/forms.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/forms.py new file mode 100644 index 0000000..3f1cd9a --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/forms.py @@ -0,0 +1,39 @@ +from django.core.exceptions import ValidationError +from django.forms import ( + BooleanField, + CharField, + ChoiceField, + EmailField, + Form, + IntegerField, + Textarea, +) + + +class ContactForm(Form): + name = CharField(max_length=100) + email = EmailField() + age = IntegerField(min_value=0, max_value=150) + message = CharField(widget=Textarea, required=False) + agree = BooleanField(required=False) + category = ChoiceField( + choices=[ + ("general", "General"), + ("support", "Support"), + ("feedback", "Feedback"), + ] + ) + + def clean_name(self): + name = self.cleaned_data["name"] + if name == "banned": + raise ValidationError("This name is not allowed") + return name + + def clean(self): + cleaned_data = super().clean() + age = cleaned_data.get("age") + category = cleaned_data.get("category") + if age is not None and age < 18 and category == "support": + raise ValidationError("Support requests require an adult") + return cleaned_data diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/middleware.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/middleware.py new file mode 100644 index 0000000..b547305 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/middleware.py @@ -0,0 +1,17 @@ +from asgiref.sync import iscoroutinefunction, markcoroutinefunction + + +class CustomAsyncMiddleware: + async_capable = True + sync_capable = False + + def __init__(self, get_response): + self.get_response = get_response + if iscoroutinefunction(self.get_response): + markcoroutinefunction(self) + + async def __call__(self, request): + request.custom_middleware_applied = True + response = await self.get_response(request) + response["X-Custom-Middleware"] = "applied" + return response diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/templatetags/__init__.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/templatetags/custom_tags.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/templatetags/custom_tags.py new file mode 100644 index 0000000..55387ea --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/templatetags/custom_tags.py @@ -0,0 +1,13 @@ +from django import template + +register = template.Library() + + +@register.simple_tag +def greet(name): + return f"Hello, {name}!" + + +@register.filter +def multiply(value, arg): + return value * arg diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/urls.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/urls.py new file mode 100644 index 0000000..ef6e866 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/urls.py @@ -0,0 +1,67 @@ +from django.urls import include, path, re_path +from rest_framework.routers import DefaultRouter + +from django_app import views + +router = DefaultRouter() + + +urlpatterns = [ + path("hello/", views.hello, name="hello"), + path("status//", views.status_code, name="status-code"), + path("echo-method/", views.echo_method, name="echo-method"), + path("echo-headers/", views.echo_headers, name="echo-headers"), + path("echo-query/", views.echo_query, name="echo-query"), + path("echo-body/", views.echo_body, name="echo-body"), + path("echo-form/", views.echo_form, name="echo-form"), + path("items//", views.item_detail, name="item-detail"), + path("users//", views.user_detail, name="user-detail"), + path("posts//", views.post_detail, name="post-detail"), + path("uuids//", views.uuid_detail, name="uuid-detail"), + re_path(r"^archive/(?P[0-9]{4})/$", views.archive_year, name="archive-year"), + path("api/v1/", include(("django_app.api_urls", "api"), namespace="v1")), + path("reverse-test/", views.reverse_test, name="reverse-test"), + path("template/hello/", views.template_hello, name="template-hello"), + path("template/context/", views.template_context, name="template-context"), + path( + "template/inheritance/", views.template_inheritance, name="template-inheritance" + ), + path("form/validate/", views.form_validate, name="form-validate"), + path("form/process/", views.form_process, name="form-process"), + path("trigger-404/", views.trigger_404, name="trigger-404"), + path("trigger-403/", views.trigger_403, name="trigger-403"), + path("trigger-400/", views.trigger_400, name="trigger-400"), + path("trigger-500/", views.trigger_500, name="trigger-500"), + path("session/set/", views.session_set, name="session-set"), + path("session/get/", views.session_get, name="session-get"), + path("session/flush/", views.session_flush, name="session-flush"), + path("stream/async-gen/", views.stream_async_gen, name="stream-async-gen"), + path("stream/sync-gen/", views.stream_sync_gen, name="stream-sync-gen"), + path("csrf/form/", views.csrf_form, name="csrf-form"), + path("csrf/exempt/", views.csrf_exempt_view, name="csrf-exempt"), + path("cache/set/", views.cache_set, name="cache-set"), + path("cache/get/", views.cache_get, name="cache-get"), + path("cache/delete/", views.cache_delete, name="cache-delete"), + path("cache/clear/", views.cache_clear, name="cache-clear"), + path("signals/send/", views.signal_send, name="signal-send"), + path("signals/robust/", views.signal_robust, name="signal-robust"), + path("auth/login/", views.auth_login, name="auth-login"), + path("auth/logout/", views.auth_logout, name="auth-logout"), + path("auth/user/", views.auth_user, name="auth-user"), + path("auth/protected/", views.auth_protected, name="auth-protected"), + path("auth/permission/", views.auth_permission, name="auth-permission"), + path("async/sync-to-async/", views.sync_to_async_view, name="sync-to-async"), + path("async/async-iter/", views.async_iter_view, name="async-iter"), + path("upload/single/", views.upload_single, name="upload-single"), + path("upload/multiple/", views.upload_multiple, name="upload-multiple"), + path("paginate/", views.paginate_view, name="paginate"), + path("cbv/", views.AsyncCBV.as_view(), name="async-cbv"), + path("drf/", include(router.urls)), + path("drf/", include("django_app.drf_views")), +] + + +handler400 = "django_app.views.custom_handler400" +handler403 = "django_app.views.custom_handler403" +handler404 = "django_app.views.custom_handler404" +handler500 = "django_app.views.custom_handler500" diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/django_app/views.py b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/views.py new file mode 100644 index 0000000..f050451 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/django_app/views.py @@ -0,0 +1,363 @@ +import json + +from asgiref.sync import sync_to_async +from django.contrib.auth import aauthenticate, alogin, alogout +from django.contrib.auth.decorators import login_required, permission_required +from django.core.cache import cache +from django.core.exceptions import PermissionDenied, SuspiciousOperation +from django.core.paginator import Paginator +from django.dispatch import Signal +from django.http import Http404, HttpResponse, JsonResponse, StreamingHttpResponse +from django.shortcuts import render +from django.urls import reverse +from django.utils.html import escape +from django.views import View +from django.views.decorators.csrf import ( + csrf_exempt, + csrf_protect, + ensure_csrf_cookie, +) + +from django_app.forms import ContactForm + +custom_signal = Signal() +signal_events = [] + + +def signal_receiver(sender, **kwargs): + data = kwargs.get("data") + signal_events.append(data) + return data + + +custom_signal.connect(signal_receiver, dispatch_uid="django_app_signal_receiver") + + +def _json_body(request): + if not request.body: + return {} + return json.loads(request.body.decode()) + + +async def hello(request): + return HttpResponse("hello") + + +async def status_code(request, code): + return HttpResponse(f"status {escape(str(code))}", status=code) + + +async def echo_method(request): + return JsonResponse({"method": request.method}) + + +async def echo_headers(request): + return JsonResponse({k.lower(): v for k, v in request.headers.items()}) + + +async def echo_query(request): + return JsonResponse(request.GET.dict()) + + +async def echo_body(request): + return JsonResponse(_json_body(request)) + + +async def echo_form(request): + return JsonResponse(request.POST.dict()) + + +async def item_detail(request, id): + return JsonResponse({"id": id, "type": type(id).__name__}) + + +async def user_detail(request, name): + return JsonResponse({"name": name}) + + +async def post_detail(request, slug): + return JsonResponse({"slug": slug}) + + +async def uuid_detail(request, uid): + return JsonResponse({"uuid": str(uid)}) + + +async def archive_year(request, year): + return JsonResponse({"year": year}) + + +async def reverse_test(request): + return HttpResponse(reverse("hello")) + + +async def template_hello(request): + return render(request, "hello.html", {"name": "World"}) + + +async def template_context(request): + return render( + request, + "context_test.html", + {"items": [1, 2, 3], "show_items": True, "greeting": "hello"}, + ) + + +async def template_inheritance(request): + return render(request, "base.html") + + +async def form_validate(request): + if request.method == "POST": + form = ContactForm(request.POST) + if form.is_valid(): + return JsonResponse({"valid": True, "data": form.cleaned_data}) + return JsonResponse({"valid": False, "errors": form.errors.get_json_data()}) + form = ContactForm() + return HttpResponse(f"
{form.as_div()}
") + + +async def form_process(request): + form = ContactForm(request.POST or None) + valid = form.is_valid() if request.method == "POST" else False + return JsonResponse( + { + "valid": valid, + "data": form.cleaned_data if valid else None, + "errors": None + if valid + else form.errors.get_json_data() + if request.method == "POST" + else None, + } + ) + + +async def trigger_404(request): + raise Http404("not found") + + +async def trigger_403(request): + raise PermissionDenied("forbidden") + + +async def trigger_400(request): + raise SuspiciousOperation("bad request") + + +async def trigger_500(request): + raise RuntimeError("server error") + + +def custom_handler400(request, exception): + return JsonResponse({"error": "bad_request", "detail": str(exception)}, status=400) + + +def custom_handler403(request, exception): + return JsonResponse({"error": "forbidden", "detail": str(exception)}, status=403) + + +def custom_handler404(request, exception): + return JsonResponse({"error": "not_found", "detail": str(exception)}, status=404) + + +def custom_handler500(request): + return JsonResponse({"error": "server_error"}, status=500) + + +async def session_set(request): + payload = _json_body(request) + for key, value in payload.items(): + request.session[key] = value + return JsonResponse({"set": list(payload.keys())}) + + +async def session_get(request): + return JsonResponse(dict(request.session.items())) + + +async def session_flush(request): + await request.session.aflush() + return JsonResponse({"flushed": True}) + + +async def _async_stream(): + for index in range(5): + yield f"chunk-{index}\n" + + +def _sync_stream(): + for index in range(5): + yield f"chunk-{index}\n" + + +async def stream_async_gen(request): + return StreamingHttpResponse(_async_stream(), content_type="text/plain") + + +async def stream_sync_gen(request): + return StreamingHttpResponse(_sync_stream(), content_type="text/plain") + + +@csrf_protect +@ensure_csrf_cookie +async def csrf_form(request): + if request.method == "POST": + return JsonResponse({"csrf": "ok"}) + return render(request, "csrf_form.html") + + +@csrf_exempt +async def csrf_exempt_view(request): + return JsonResponse({"csrf_exempt": True}) + + +async def cache_set(request): + payload = _json_body(request) + key = payload.get("key") + value = payload.get("value") + await cache.aset(key, value) + return JsonResponse({"cached": True, "key": key, "value": value}) + + +async def cache_get(request): + key = request.GET.get("key") + value = await cache.aget(key) + return JsonResponse({"key": key, "value": value}) + + +async def cache_delete(request): + key = request.GET.get("key") + await cache.adelete(key) + return JsonResponse({"deleted": True, "key": key}) + + +async def cache_clear(request): + await cache.aclear() + return JsonResponse({"cleared": True}) + + +async def signal_send(request): + signal_events.clear() + await custom_signal.asend(sender=None, data="test") + return JsonResponse({"sent": True, "received": list(signal_events)}) + + +async def signal_robust(request): + signal_events.clear() + responses = await custom_signal.asend_robust(sender=None, data="test") + results = [] + for receiver, response in responses: + results.append( + { + "receiver": getattr(receiver, "__name__", receiver.__class__.__name__), + "response": str(response), + } + ) + return JsonResponse({"sent": True, "results": results}) + + +async def auth_login(request): + payload = _json_body(request) + user = await aauthenticate( + request, + username=payload.get("username"), + password=payload.get("password"), + ) + if user is None: + return JsonResponse({"authenticated": False}) + await alogin(request, user, backend="django_app.auth_backend.InMemoryBackend") + return JsonResponse({"authenticated": True}) + + +async def auth_logout(request): + await alogout(request) + return JsonResponse({"logged_out": True}) + + +async def auth_user(request): + user = await request.auser() + return JsonResponse( + { + "is_authenticated": user.is_authenticated, + "username": getattr(user, "username", ""), + } + ) + + +@login_required +async def auth_protected(request): + user = await request.auser() + return JsonResponse({"protected": True, "user": user.username}) + + +@permission_required("can_view") +async def auth_permission(request): + return JsonResponse({"permission": True}) + + +def _sync_utility(): + return "sync result" + + +async def sync_to_async_view(request): + result = await sync_to_async(_sync_utility)() + return JsonResponse({"result": result}) + + +async def _async_numbers(): + for value in range(5): + yield value + + +async def async_iter_view(request): + items = [value async for value in _async_numbers()] + return JsonResponse({"items": items}) + + +async def upload_single(request): + uploaded_file = request.FILES["file"] + return JsonResponse( + { + "name": uploaded_file.name, + "size": uploaded_file.size, + "content": uploaded_file.read().decode(), + } + ) + + +async def upload_multiple(request): + files = [{"name": f.name, "size": f.size} for f in request.FILES.getlist("file")] + return JsonResponse({"files": files}) + + +async def paginate_view(request): + items = list(range(1, 51)) + paginator = Paginator(items, 10) + page = paginator.get_page(request.GET.get("page", 1)) + return JsonResponse( + { + "page": page.number, + "items": list(page.object_list), + "num_pages": paginator.num_pages, + "has_next": page.has_next(), + "has_previous": page.has_previous(), + } + ) + + +class AsyncCBV(View): + async def get(self, request): + return JsonResponse({"method": "GET"}) + + async def post(self, request): + return JsonResponse({"method": "POST"}) + + async def put(self, request): + return JsonResponse({"method": "PUT"}) + + async def delete(self, request): + return JsonResponse({"method": "DELETE"}) + + async def patch(self, request): + return JsonResponse({"method": "PATCH"}) diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/templates/base.html b/packages/cli/tests/web-frameworks-test/django-async/src/templates/base.html new file mode 100644 index 0000000..b25aebe --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/templates/base.html @@ -0,0 +1,5 @@ + + +{% block title %}Base{% endblock %} +{% block content %}Base content{% endblock %} + diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/templates/context_test.html b/packages/cli/tests/web-frameworks-test/django-async/src/templates/context_test.html new file mode 100644 index 0000000..ebe9795 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/templates/context_test.html @@ -0,0 +1,6 @@ +{% if show_items %} +{% for item in items %}{{ item }}{% if not forloop.last %},{% endif %}{% endfor %} +{% endif %} +{{ greeting|upper }} +{% greet "Worker" %} +{{ 3|multiply:4 }} diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/templates/csrf_form.html b/packages/cli/tests/web-frameworks-test/django-async/src/templates/csrf_form.html new file mode 100644 index 0000000..aa94a2c --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/templates/csrf_form.html @@ -0,0 +1 @@ +
{% csrf_token %}
diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/templates/hello.html b/packages/cli/tests/web-frameworks-test/django-async/src/templates/hello.html new file mode 100644 index 0000000..ccf82f1 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/templates/hello.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}Hello{% endblock %} +{% block content %}Hello, {{ name }}!{% endblock %} diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_async_utilities.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_async_utilities.py new file mode 100644 index 0000000..a6e9cde --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_async_utilities.py @@ -0,0 +1,19 @@ +import pytest +from _client import get_json + + +@pytest.mark.asyncio +async def test_sync_to_async(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/async/sync-to-async/") + + assert response.status == 200 + assert payload["result"] == "sync result" + + +@pytest.mark.asyncio +async def test_async_iterator(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/async/async-iter/") + + assert response.status == 200 + assert payload["items"] + assert payload["items"] == [0, 1, 2, 3, 4] diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_auth.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_auth.py new file mode 100644 index 0000000..9bc959c --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_auth.py @@ -0,0 +1,86 @@ +import pytest +from _client import cookie_value, fetch, get_json, post_json, read_json + + +async def _login(django_asgi_app, password="testpass123"): + response = await post_json( + django_asgi_app, + "/auth/login/", + {"username": "testuser", "password": password}, + ) + payload = await read_json(response) + sessionid = cookie_value(response.headers.get("Set-Cookie"), "sessionid") + return response, payload, sessionid + + +@pytest.mark.asyncio +async def test_auth_login_success(django_asgi_app): + response, payload, _ = await _login(django_asgi_app) + + assert response.status == 200 + assert payload["authenticated"] is True + + +@pytest.mark.asyncio +async def test_auth_login_failure(django_asgi_app): + response, payload, _ = await _login(django_asgi_app, password="wrong-password") + + assert response.status == 200 + assert payload["authenticated"] is False + + +@pytest.mark.asyncio +async def test_auth_user_anonymous(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/auth/user/") + + assert response.status == 200 + assert payload["is_authenticated"] is False + + +@pytest.mark.asyncio +async def test_auth_user_after_login(django_asgi_app): + _, _, sessionid = await _login(django_asgi_app) + assert sessionid is not None + + response, payload = await get_json( + django_asgi_app, "/auth/user/", headers={"Cookie": f"sessionid={sessionid}"} + ) + + assert response.status == 200 + assert payload["is_authenticated"] is True + assert payload["username"] == "testuser" + + +@pytest.mark.asyncio +async def test_auth_logout(django_asgi_app): + _, _, sessionid = await _login(django_asgi_app) + assert sessionid is not None + + logout_response = await post_json( + django_asgi_app, + "/auth/logout/", + {}, + headers={"Cookie": f"sessionid={sessionid}"}, + ) + assert logout_response.status == 200 + cleared = cookie_value(logout_response.headers.get("Set-Cookie"), "sessionid") + assert cleared is not None + assert cleared.strip('"') == "" + + +@pytest.mark.asyncio +async def test_auth_protected_access(django_asgi_app): + _, _, sessionid = await _login(django_asgi_app) + assert sessionid is not None + + anon = await fetch(django_asgi_app, "/auth/protected/") + assert anon.status == 302 + assert anon.headers.get("Location") is not None + + response, payload = await get_json( + django_asgi_app, + "/auth/protected/", + headers={"Cookie": f"sessionid={sessionid}"}, + ) + assert response.status == 200 + assert payload["protected"] is True diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_caching.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_caching.py new file mode 100644 index 0000000..a32cc54 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_caching.py @@ -0,0 +1,62 @@ +import pytest +from _client import get_json, post_json, read_json + + +@pytest.mark.asyncio +async def test_cache_set_and_get(django_asgi_app): + response = await post_json( + django_asgi_app, "/cache/set/", {"key": "k1", "value": "v1"} + ) + assert response.status == 200 + assert await read_json(response) == {"cached": True, "key": "k1", "value": "v1"} + + response, payload = await get_json(django_asgi_app, "/cache/get/?key=k1") + assert response.status == 200 + assert payload == {"key": "k1", "value": "v1"} + + +@pytest.mark.asyncio +async def test_cache_delete_and_missing(django_asgi_app): + await post_json( + django_asgi_app, "/cache/set/", {"key": "delete-key", "value": "delete-value"} + ) + response, payload = await get_json(django_asgi_app, "/cache/delete/?key=delete-key") + assert response.status == 200 + assert payload == {"deleted": True, "key": "delete-key"} + + response, payload = await get_json(django_asgi_app, "/cache/get/?key=delete-key") + assert response.status == 200 + assert payload == {"key": "delete-key", "value": None} + + response, payload = await get_json(django_asgi_app, "/cache/get/?key=nonexistent") + assert response.status == 200 + assert payload == {"key": "nonexistent", "value": None} + + +@pytest.mark.asyncio +async def test_cache_clear(django_asgi_app): + for key, value in (("clear-key-1", "value-1"), ("clear-key-2", "value-2")): + await post_json(django_asgi_app, "/cache/set/", {"key": key, "value": value}) + + response, payload = await get_json(django_asgi_app, "/cache/clear/") + assert response.status == 200 + assert payload["cleared"] is True + + for key in ("clear-key-1", "clear-key-2"): + response, payload = await get_json(django_asgi_app, f"/cache/get/?key={key}") + assert response.status == 200 + assert payload == {"key": key, "value": None} + + +@pytest.mark.asyncio +async def test_cache_overwrite(django_asgi_app): + await post_json( + django_asgi_app, "/cache/set/", {"key": "overwrite-key", "value": "value-1"} + ) + await post_json( + django_asgi_app, "/cache/set/", {"key": "overwrite-key", "value": "value-2"} + ) + + response, payload = await get_json(django_asgi_app, "/cache/get/?key=overwrite-key") + assert response.status == 200 + assert payload == {"key": "overwrite-key", "value": "value-2"} diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_cors.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_cors.py new file mode 100644 index 0000000..82b9c20 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_cors.py @@ -0,0 +1,46 @@ +import pytest +from _client import fetch + +ORIGIN_HEADER = "Access-Control-Allow-Origin" +CREDENTIALS_HEADER = "Access-Control-Allow-Credentials" + + +@pytest.mark.asyncio +async def test_cors_allowed_origins(django_asgi_app): + cases = ( + ("OPTIONS", "http://localhost:3000", True), + ("GET", "https://example.com", False), + ("GET", "https://sub.example.com", False), + ("GET", "http://localhost:3000", True), + ) + for method, origin, with_credentials in cases: + headers = {"Origin": origin} + if method == "OPTIONS": + headers["Access-Control-Request-Method"] = "GET" + response = await fetch( + django_asgi_app, "/hello/", method=method, headers=headers + ) + + assert response.headers.get(ORIGIN_HEADER) == origin + if with_credentials: + assert response.headers.get(CREDENTIALS_HEADER) == "true" + + +@pytest.mark.asyncio +async def test_cors_disallowed_origin(django_asgi_app): + response = await fetch( + django_asgi_app, + "/hello/", + method="OPTIONS", + headers={"Origin": "http://evil.com", "Access-Control-Request-Method": "GET"}, + ) + + assert not response.headers.has(ORIGIN_HEADER) + + +@pytest.mark.asyncio +async def test_cors_no_origin_no_headers(django_asgi_app): + response = await fetch(django_asgi_app, "/hello/") + + assert not response.headers.has(ORIGIN_HEADER) + assert not response.headers.has(CREDENTIALS_HEADER) diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_csrf.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_csrf.py new file mode 100644 index 0000000..bd56d2f --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_csrf.py @@ -0,0 +1,69 @@ +import re + +import pytest +from _client import cookie_value, fetch, post_json, read_json + + +@pytest.mark.asyncio +async def test_csrf_form_renders_token(django_asgi_app): + response = await fetch(django_asgi_app, "/csrf/form/") + body = await response.text() + cookie_token = cookie_value(response.headers.get("Set-Cookie"), "csrftoken") + + assert response.status == 200 + assert "csrfmiddlewaretoken" in body or "csrf_token" in body + assert cookie_token is not None + + +@pytest.mark.asyncio +async def test_post_without_csrf_rejected(django_asgi_app): + response = await fetch( + django_asgi_app, + "/csrf/form/", + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body="field=value", + ) + + assert response.status == 403 + + +@pytest.mark.asyncio +async def test_csrf_exempt_allows_post(django_asgi_app): + response = await post_json(django_asgi_app, "/csrf/exempt/", {"value": "ok"}) + + assert response.status == 200 + assert await read_json(response) == {"csrf_exempt": True} + + +@pytest.mark.asyncio +async def test_csrf_form_token_post(django_asgi_app): + get_response = await fetch(django_asgi_app, "/csrf/form/") + body = await get_response.text() + form_token = None + for pattern in ( + r'name="csrfmiddlewaretoken" value="([^"]+)"', + r"csrfmiddlewaretoken.+?value=['\"]([^'\"]+)['\"]", + ): + match = re.search(pattern, body) + if match: + form_token = match.group(1) + break + cookie_token = cookie_value(get_response.headers.get("Set-Cookie"), "csrftoken") + + assert form_token is not None + assert cookie_token is not None + + post_response = await fetch( + django_asgi_app, + "/csrf/form/", + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Cookie": f"csrftoken={cookie_token}", + "X-CSRFToken": cookie_token, + }, + body=f"csrfmiddlewaretoken={form_token}&field=value", + ) + + assert post_response.status == 200 diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_auth_permissions.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_auth_permissions.py new file mode 100644 index 0000000..c1bbd9f --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_auth_permissions.py @@ -0,0 +1,47 @@ +import pytest +from _client import fetch, get_json + + +@pytest.mark.asyncio +async def test_auth_and_permission_cases(django_asgi_app): + cases = [ + ("/drf/auth/", None, {401, 403}, None), + ("/drf/public/", None, 200, {"public": True}), + ( + "/drf/admin/", + {"Authorization": "Bearer test-token-123"}, + 403, + None, + ), + ( + "/drf/admin/", + {"Authorization": "Bearer admin-token-456"}, + 200, + {"admin": True}, + ), + ( + "/drf/custom-auth/", + {"Authorization": "Bearer test-token-123"}, + 200, + {"user": "testuser", "auth": "token"}, + ), + ( + "/drf/custom-auth/", + {"Authorization": "Bearer invalid-token"}, + 401, + None, + ), + ("/drf/custom-auth/", None, {401, 403}, None), + ] + + for path, headers, status, expected in cases: + if expected is None: + response = await fetch(django_asgi_app, path, headers=headers) + if isinstance(status, set): + assert response.status in status + else: + assert response.status == status + else: + response, payload = await get_json(django_asgi_app, path, headers=headers) + assert response.status == status + assert payload == expected diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_content_negotiation.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_content_negotiation.py new file mode 100644 index 0000000..2cf7d62 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_content_negotiation.py @@ -0,0 +1,41 @@ +import json + +import pytest +from _client import post_form, post_json + + +@pytest.mark.asyncio +async def test_content_negotiation_cases(django_asgi_app): + json_data = {"name": "widget", "quantity": 2} + response = await post_json(django_asgi_app, "/drf/content/", json_data) + payload = json.loads(await response.text()) + + assert response.status == 200 + assert payload == {"content_type": "application/json", "data": json_data} + assert "application/json" in (response.headers.get("Content-Type") or "") + + response = await post_form( + django_asgi_app, + "/drf/content/", + "name=widget&quantity=2", + ) + payload = json.loads(await response.text()) + + assert response.status == 200 + assert payload["content_type"] == "application/x-www-form-urlencoded" + assert payload["data"]["name"] == "widget" + assert payload["data"]["quantity"] == "2" + + response = await post_json(django_asgi_app, "/drf/content/", {"ping": "pong"}) + + assert response.status == 200 + assert "application/json" in (response.headers.get("Content-Type") or "") + + response = await post_form( + django_asgi_app, + "/drf/content/", + "", + headers={"Content-Type": "text/xml"}, + ) + + assert response.status == 415 diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_serializers.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_serializers.py new file mode 100644 index 0000000..50419b8 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_serializers.py @@ -0,0 +1,68 @@ +import json + +import pytest +from _client import get_json, post_json + + +@pytest.mark.asyncio +async def test_serializer_and_nested_cases(django_asgi_app): + serializer_cases = [ + ( + {"name": "widget", "price": 9.99, "quantity": 3}, + True, + lambda payload: ( + payload["data"]["name"] == "widget" + and float(payload["data"]["price"]) == pytest.approx(9.99) + and payload["data"]["quantity"] == 3 + ), + ), + ( + {"name": "widget"}, + False, + lambda payload: "price" in payload["errors"] + and "quantity" in payload["errors"], + ), + ( + {"name": "invalid", "price": 9.99, "quantity": 1}, + False, + lambda payload: "name" in payload["errors"], + ), + ( + {"name": "bulk", "price": 1500, "quantity": 200}, + False, + lambda payload: "bulk order too large" in str(payload["errors"]).lower(), + ), + ] + + for data, valid, check in serializer_cases: + response = await post_json(django_asgi_app, "/drf/serializer/", data) + payload = json.loads(await response.text()) + assert payload["valid"] is valid + assert check(payload) + + response, payload = await get_json(django_asgi_app, "/drf/serializer/") + assert response.status == 200 + assert "name" in payload + assert "price" in payload + assert "quantity" in payload + + nested_cases = [ + ( + {"customer_name": "Alice", "items": [{"product": "A", "quantity": 2}]}, + True, + lambda payload: payload["data"]["customer_name"] == "Alice" + and payload["data"]["items"][0]["product"] == "A" + and payload["data"]["items"][0]["quantity"] == 2, + ), + ( + {"customer_name": "Bob", "items": []}, + False, + lambda payload: "items" in payload["errors"], + ), + ] + + for data, valid, check in nested_cases: + response = await post_json(django_asgi_app, "/drf/nested/", data) + payload = json.loads(await response.text()) + assert payload["valid"] is valid + assert check(payload) diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_throttling.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_throttling.py new file mode 100644 index 0000000..6b3f1d2 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_throttling.py @@ -0,0 +1,16 @@ +import json + +import pytest +from _client import fetch + + +@pytest.mark.asyncio +async def test_throttle_behavior(django_asgi_app): + await fetch(django_asgi_app, "/cache/clear/") + responses = [await fetch(django_asgi_app, "/drf/throttled/") for _ in range(4)] + payload = json.loads(await responses[3].text()) + + assert [response.status for response in responses[:3]] == [200, 200, 200] + assert responses[3].status == 429 + assert responses[3].headers.get("Retry-After") + assert "detail" in payload diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_versioning.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_versioning.py new file mode 100644 index 0000000..b67c768 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_versioning.py @@ -0,0 +1,25 @@ +import pytest +from _client import get_json + + +@pytest.mark.asyncio +async def test_versioning_cases(django_asgi_app): + cases = [ + ("/drf/versioned/?version=2.0", None, "2.0"), + ("/drf/versioned/", None, None), + ("/drf/versioned/", {"Accept": "application/json; version=3.0"}, None), + ("/drf/versioned/?version=1.0", None, None), + ] + + for path, headers, expected_version in cases: + response, payload = await get_json(django_asgi_app, path, headers=headers) + assert response.status == 200 + if expected_version is not None: + assert payload["version"] == expected_version + elif path.endswith("version=1.0"): + assert isinstance(payload, dict) + assert "version" in payload + elif headers: + assert payload["version"] in {"3.0", "default"} + else: + assert payload["version"] diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_views.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_views.py new file mode 100644 index 0000000..24eb617 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_drf_views.py @@ -0,0 +1,73 @@ +import json + +import pytest +from _client import fetch, get_json, post_json + + +@pytest.mark.asyncio +async def test_api_view_and_function_view(django_asgi_app): + cases = [ + ("GET", "/drf/api-view/", None, 200, {"method": "GET", "query": {}}), + ( + "GET", + "/drf/api-view/?foo=bar", + None, + 200, + {"method": "GET", "query": {"foo": "bar"}}, + ), + ( + "POST", + "/drf/api-view/", + {"key": "val"}, + 201, + {"method": "POST", "data": {"key": "val"}}, + ), + ("PUT", "/drf/api-view/", {"updated": True}, 200, None), + ("DELETE", "/drf/api-view/", None, 204, None), + ( + "GET", + "/drf/function-view/", + None, + 200, + {"message": "hello from function view"}, + ), + ( + "POST", + "/drf/function-view/", + {"hello": "world"}, + 201, + {"received": {"hello": "world"}}, + ), + ] + + for method, path, data, status, expected in cases: + if method == "GET": + response, payload = await get_json(django_asgi_app, path) + elif method == "DELETE": + response = await fetch(django_asgi_app, path, method=method) + payload = None + elif method == "PUT": + response = await fetch( + django_asgi_app, + path, + method=method, + headers={"Content-Type": "application/json"}, + body=json.dumps(data), + ) + payload = json.loads(await response.text()) + else: + response = await post_json(django_asgi_app, path, data) + payload = json.loads(await response.text()) + assert response.status == status + if expected is not None: + assert payload == expected + if path == "/drf/api-view/" and method == "PUT": + assert isinstance(payload, dict) + assert payload["method"] == "PUT" + + +@pytest.mark.asyncio +async def test_method_not_allowed(django_asgi_app): + response = await fetch(django_asgi_app, "/drf/function-view/", method="PATCH") + + assert response.status == 405 diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_error_handling.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_error_handling.py new file mode 100644 index 0000000..b2ecae7 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_error_handling.py @@ -0,0 +1,32 @@ +import pytest +from _client import fetch, get_json + + +@pytest.mark.asyncio +async def test_error_handlers(django_asgi_app): + for path, status in ( + ("/trigger-404/", 404), + ("/trigger-403/", 403), + ("/trigger-400/", 400), + ("/trigger-500/", 500), + ): + response, payload = await get_json(django_asgi_app, path) + + assert response.status == status + assert isinstance(payload, dict) + assert payload + + +@pytest.mark.asyncio +async def test_unmatched_404(django_asgi_app): + response = await fetch(django_asgi_app, "/nonexistent-path-xyz/") + + assert response.status == 404 + + +@pytest.mark.asyncio +async def test_error_response_is_json(django_asgi_app): + response = await fetch(django_asgi_app, "/trigger-404/") + + assert response.status == 404 + assert "application/json" in (response.headers.get("Content-Type") or "") diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_file_uploads.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_file_uploads.py new file mode 100644 index 0000000..79d69e9 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_file_uploads.py @@ -0,0 +1,57 @@ +import pytest +from _client import build_multipart, get_json + + +def _normalize_files_payload(payload): + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + if "files" in payload: + return payload["files"] + return [payload] + return [] + + +@pytest.mark.asyncio +async def test_single_file_upload(django_asgi_app): + for filename, content, expected in ( + ("test.txt", "hello", {"name": "test.txt", "content": "hello", "size": 5}), + ( + "meta.txt", + "metadata", + {"name": "meta.txt", "size": len("metadata")}, + ), + ("content.txt", "known content", {"content": "known content"}), + ): + body, content_type = build_multipart([("file", filename, content)]) + response, payload = await get_json( + django_asgi_app, + "/upload/single/", + method="POST", + headers={"Content-Type": content_type}, + body=body, + ) + + assert response.status == 200 + for key, value in expected.items(): + assert payload[key] == value + + +@pytest.mark.asyncio +async def test_multiple_file_upload(django_asgi_app): + body, content_type = build_multipart( + [("file", "one.txt", "one"), ("file", "two.txt", "two")] + ) + response, payload = await get_json( + django_asgi_app, + "/upload/multiple/", + method="POST", + headers={"Content-Type": content_type}, + body=body, + ) + files = _normalize_files_payload(payload) + + assert response.status == 200 + assert len(files) == 2 + assert [file_info["name"] for file_info in files] == ["one.txt", "two.txt"] + assert all(file_info["size"] > 0 for file_info in files) diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_forms.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_forms.py new file mode 100644 index 0000000..9c47ab7 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_forms.py @@ -0,0 +1,50 @@ +import json + +import pytest +from _client import fetch, post_form + + +@pytest.mark.asyncio +async def test_form_validation(django_asgi_app): + for label, body in ( + ( + "valid", + "name=alice&email=alice%40example.com&age=21&category=general&message=hello", + ), + ("missing_required", "name=alice"), + ("invalid_email", "name=alice&email=not-an-email&age=21&category=general"), + ( + "custom_clean", + "name=banned&email=user%40example.com&age=21&category=general", + ), + ("cross_field", "name=alice&email=alice%40example.com&age=17&category=support"), + ): + response = await post_form(django_asgi_app, "/form/validate/", body) + + assert response.status == 200 + payload = json.loads(await response.text()) + if label == "valid": + assert payload["valid"] is True + assert payload.get("data") + elif label == "missing_required": + assert payload["valid"] is False + assert payload.get("errors") + elif label == "invalid_email": + assert payload["valid"] is False + assert "email" in payload.get("errors", {}) + elif label == "custom_clean": + assert payload["valid"] is False + assert "name" in payload.get("errors", {}) + else: + assert payload["valid"] is False + assert payload.get("errors") + + +@pytest.mark.asyncio +async def test_form_get_renders_html(django_asgi_app): + response = await fetch(django_asgi_app, "/form/validate/") + + assert response.status == 200 + body = await response.text() + assert "= 0 + + +@pytest.mark.asyncio +async def test_session_middleware_cookie(django_asgi_app): + response = await post_json(django_asgi_app, "/session/set/", {"color": "blue"}) + + assert response.status == 200 + assert response.headers.get("Set-Cookie") is not None diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_pagination.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_pagination.py new file mode 100644 index 0000000..dba6657 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_pagination.py @@ -0,0 +1,36 @@ +import pytest +from _client import get_json + + +@pytest.mark.asyncio +async def test_paginator_basic_pages(django_asgi_app): + for page, expected in ( + (1, {"has_next": True, "has_previous": False}), + (3, {"has_next": True, "has_previous": True}), + (5, {"has_next": False, "has_previous": True}), + ): + response, payload = await get_json(django_asgi_app, f"/paginate/?page={page}") + + assert response.status == 200 + assert len(payload["items"]) == 10 + assert payload["num_pages"] == 5 + for key, value in expected.items(): + assert payload[key] is value + + +@pytest.mark.asyncio +async def test_paginator_invalid_page(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/paginate/?page=abc") + + assert response.status == 200 + assert payload["page"] == 1 + assert len(payload["items"]) == 10 + + +@pytest.mark.asyncio +async def test_paginator_out_of_range(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/paginate/?page=999") + + assert response.status == 200 + assert payload["page"] == payload["num_pages"] == 5 + assert payload["has_next"] is False diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_request_response.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_request_response.py new file mode 100644 index 0000000..8d3ac1d --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_request_response.py @@ -0,0 +1,98 @@ +import json + +import pytest +from _client import fetch, get_json, post_form, post_json + + +@pytest.mark.asyncio +async def test_request_method(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/echo-method/") + + assert response.status == 200 + assert payload["method"] == "GET" + + +@pytest.mark.asyncio +async def test_request_get_params(django_asgi_app): + for path, payload in ( + ("/echo-query/?key=value", {"key": "value"}), + ("/echo-query/?a=1&b=2&c=3", {"a": "1", "b": "2", "c": "3"}), + ): + response, data = await get_json(django_asgi_app, path) + + assert response.status == 200 + assert data == payload + + +@pytest.mark.asyncio +async def test_request_post_bodies(django_asgi_app): + for make_request, payload in ( + ( + lambda: post_form( + django_asgi_app, + "/echo-form/", + "name=alice&role=worker", + ), + {"name": "alice", "role": "worker"}, + ), + ( + lambda: post_json( + django_asgi_app, + "/echo-body/", + {"name": "alice", "active": True}, + ), + {"name": "alice", "active": True}, + ), + ): + response = await make_request() + + assert response.status == 200 + assert json.loads(await response.text()) == payload + + +@pytest.mark.asyncio +async def test_request_headers(django_asgi_app): + response, payload = await get_json( + django_asgi_app, + "/echo-headers/", + headers={"X-Test-Header": "worker", "X-Trace-Id": "123"}, + ) + + assert response.status == 200 + payload = {key.lower(): value for key, value in payload.items()} + assert payload["x-test-header"] == "worker" + assert payload["x-trace-id"] == "123" + + +@pytest.mark.asyncio +async def test_request_path_and_content_type(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/echo-method/") + + assert response.status == 200 + assert payload["method"] == "GET" + assert "application/json" in (response.headers.get("Content-Type") or "") + + +@pytest.mark.asyncio +async def test_response_statuses(django_asgi_app): + for path, status in (("/status/201/", 201), ("/status/302/", 302)): + response = await fetch(django_asgi_app, path) + + assert response.status == status + + +@pytest.mark.asyncio +async def test_json_response_dict(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/echo-query/?a=1") + + assert response.status == 200 + assert isinstance(payload, dict) + assert payload == {"a": "1"} + + +@pytest.mark.asyncio +async def test_response_custom_headers(django_asgi_app): + response = await fetch(django_asgi_app, "/hello/") + + assert response.status == 200 + assert response.headers.get("X-Custom-Middleware") == "applied" diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_sessions.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_sessions.py new file mode 100644 index 0000000..283040f --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_sessions.py @@ -0,0 +1,95 @@ +import pytest +from _client import cookie_value, fetch, get_json, post_json + + +@pytest.mark.asyncio +async def test_session_set_and_get_values(django_asgi_app): + for data in ({"color": "blue"}, {"color": "blue", "size": "large"}): + set_response = await post_json(django_asgi_app, "/session/set/", data) + + assert set_response.status == 200 + cookie = cookie_value(set_response.headers.get("Set-Cookie"), "sessionid") + assert cookie + + get_response, payload = await get_json( + django_asgi_app, + "/session/get/", + headers={"Cookie": f"sessionid={cookie}"}, + ) + + assert get_response.status == 200 + assert payload == data + + +@pytest.mark.asyncio +async def test_session_flush(django_asgi_app): + set_response = await post_json(django_asgi_app, "/session/set/", {"color": "blue"}) + + assert set_response.status == 200 + cookie = cookie_value(set_response.headers.get("Set-Cookie"), "sessionid") + + flush_response = await fetch( + django_asgi_app, + "/session/flush/", + method="POST", + headers={"Cookie": f"sessionid={cookie}"}, + ) + + assert flush_response.status == 200 + flushed_cookie = cookie_value(flush_response.headers.get("Set-Cookie"), "sessionid") + + get_response, payload = await get_json( + django_asgi_app, + "/session/get/", + headers={"Cookie": f"sessionid={flushed_cookie or ''}"}, + ) + + assert get_response.status == 200 + assert payload == {} + + +@pytest.mark.asyncio +async def test_session_overwrites(django_asgi_app): + first_response = await post_json( + django_asgi_app, "/session/set/", {"color": "blue"} + ) + + assert first_response.status == 200 + cookie = cookie_value(first_response.headers.get("Set-Cookie"), "sessionid") + + second_response = await post_json( + django_asgi_app, + "/session/set/", + {"color": "red"}, + headers={"Cookie": f"sessionid={cookie}"}, + ) + + assert second_response.status == 200 + updated_cookie = cookie_value( + second_response.headers.get("Set-Cookie"), "sessionid" + ) + + get_response, payload = await get_json( + django_asgi_app, + "/session/get/", + headers={"Cookie": f"sessionid={updated_cookie}"}, + ) + + assert get_response.status == 200 + assert payload == {"color": "red"} + + +@pytest.mark.asyncio +async def test_session_cookie_present(django_asgi_app): + response = await post_json(django_asgi_app, "/session/set/", {"color": "blue"}) + + assert response.status == 200 + assert cookie_value(response.headers.get("Set-Cookie"), "sessionid") + + +@pytest.mark.asyncio +async def test_session_empty_initially(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/session/get/") + + assert response.status == 200 + assert payload == {} diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_signals.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_signals.py new file mode 100644 index 0000000..53d7f8a --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_signals.py @@ -0,0 +1,33 @@ +import json + +import pytest +from _client import get_json + + +@pytest.mark.asyncio +async def test_signal_send_and_data_received(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/signals/send/") + + assert response.status == 200 + assert payload["sent"] is True + assert payload["received"] + assert any("test" in json.dumps(item) for item in payload["received"]) + + +@pytest.mark.asyncio +async def test_signal_robust(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/signals/robust/") + + assert response.status == 200 + assert payload["sent"] is True + assert payload["results"] + + +@pytest.mark.asyncio +async def test_signal_multiple_calls(django_asgi_app): + first_response, first_payload = await get_json(django_asgi_app, "/signals/send/") + second_response, second_payload = await get_json(django_asgi_app, "/signals/send/") + + assert first_response.status == 200 + assert second_response.status == 200 + assert len(second_payload["received"]) >= len(first_payload["received"]) diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_streaming.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_streaming.py new file mode 100644 index 0000000..3467068 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_streaming.py @@ -0,0 +1,22 @@ +import pytest +from _client import fetch + + +@pytest.mark.asyncio +async def test_streaming_generators(django_asgi_app): + for path in ("/stream/async-gen/", "/stream/sync-gen/"): + response = await fetch(django_asgi_app, path) + body = await response.text() + + assert response.status == 200 + for index in range(5): + assert f"chunk-{index}" in body + + +@pytest.mark.asyncio +async def test_streaming_content_type(django_asgi_app): + response = await fetch(django_asgi_app, "/stream/async-gen/") + + assert response.status == 200 + assert response.headers.get("Content-Type") is not None + assert response.headers.get("Content-Type").startswith("text/plain") diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_templates.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_templates.py new file mode 100644 index 0000000..b8cd122 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_templates.py @@ -0,0 +1,34 @@ +import pytest +from _client import fetch + + +@pytest.mark.asyncio +async def test_template_hello(django_asgi_app): + response = await fetch(django_asgi_app, "/template/hello/") + + assert response.status == 200 + assert "Hello, World!" in await response.text() + + +@pytest.mark.asyncio +async def test_template_inheritance(django_asgi_app): + response = await fetch(django_asgi_app, "/template/inheritance/") + + assert response.status == 200 + body = await response.text() + assert "Base content" in body or "Child content" in body + + +@pytest.mark.asyncio +async def test_template_context_variable(django_asgi_app): + response = await fetch(django_asgi_app, "/template/context/") + + assert response.status == 200 + body = await response.text() + compact = "".join(body.split()) + assert "hello" in body.lower() + assert "1" in body and "2" in body and "3" in body + assert "HELLO" in body + assert "Hello, Worker!" in body + assert "1,2,3" in compact + assert "12" in compact diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_url_routing.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_url_routing.py new file mode 100644 index 0000000..de292d1 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_url_routing.py @@ -0,0 +1,69 @@ +import json + +import pytest +from _client import fetch, get_json + + +@pytest.mark.asyncio +async def test_path_int_converter(django_asgi_app): + for path, payload in ( + ("/items/42/", {"id": 42, "type": "int"}), + ("/users/alice/", {"name": "alice"}), + ("/posts/my-first-post/", {"slug": "my-first-post"}), + ): + response, data = await get_json(django_asgi_app, path) + + assert response.status == 200 + assert data == payload + + uid = "550e8400-e29b-41d4-a716-446655440000" + response, payload = await get_json(django_asgi_app, f"/uuids/{uid}/") + + assert response.status == 200 + assert uid in json.dumps(payload) + + +@pytest.mark.asyncio +async def test_re_path(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/archive/2024/") + + assert response.status == 200 + assert payload == {"year": "2024"} + + +@pytest.mark.asyncio +async def test_include_namespace(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/api/v1/info/") + + assert response.status == 200 + assert payload == {"namespace": "api-v1"} + + +@pytest.mark.asyncio +async def test_reverse_url(django_asgi_app): + response = await fetch(django_asgi_app, "/reverse-test/") + + assert response.status == 200 + assert "/hello/" in await response.text() + + +@pytest.mark.asyncio +async def test_unmatched_url_404(django_asgi_app): + response = await fetch(django_asgi_app, "/nonexistent/") + + assert response.status == 404 + + +@pytest.mark.asyncio +async def test_query_string_preserved(django_asgi_app): + response, payload = await get_json(django_asgi_app, "/echo-query/?foo=bar&baz=qux") + + assert response.status == 200 + assert payload == {"foo": "bar", "baz": "qux"} + + +@pytest.mark.asyncio +async def test_path_basic(django_asgi_app): + response = await fetch(django_asgi_app, "/hello/") + + assert response.status == 200 diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/test_views_basic.py b/packages/cli/tests/web-frameworks-test/django-async/src/test_views_basic.py new file mode 100644 index 0000000..071cde8 --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/test_views_basic.py @@ -0,0 +1,71 @@ +import json + +import pytest +from _client import fetch, post_json + + +@pytest.mark.asyncio +async def test_simple_async_view(django_asgi_app): + response = await fetch(django_asgi_app, "/hello/") + + assert response.status == 200 + assert await response.text() == "hello" + + +@pytest.mark.asyncio +async def test_status_codes(django_asgi_app): + for path, status in ( + ("/status/201/", 201), + ("/status/204/", 204), + ("/status/404/", 404), + ): + response = await fetch(django_asgi_app, path) + + assert response.status == status + + +@pytest.mark.asyncio +async def test_echo_method(django_asgi_app): + for method in ["GET", "POST", "PUT", "DELETE", "PATCH"]: + if method == "POST": + response = await post_json(django_asgi_app, "/echo-method/", {"ok": True}) + else: + response = await fetch(django_asgi_app, "/echo-method/", method=method) + + assert response.status == 200 + assert json.loads(await response.text()) == {"method": method} + + +@pytest.mark.asyncio +async def test_async_cbv_get(django_asgi_app): + response = await fetch(django_asgi_app, "/cbv/") + + assert response.status == 200 + assert json.loads(await response.text()) == {"method": "GET"} + + +@pytest.mark.asyncio +async def test_async_cbv_post(django_asgi_app): + response = await post_json(django_asgi_app, "/cbv/", {"hello": "world"}) + + assert response.status == 200 + assert json.loads(await response.text()) == {"method": "POST"} + + +@pytest.mark.asyncio +async def test_head_request(django_asgi_app): + response = await fetch(django_asgi_app, "/hello/", method="HEAD") + + assert response.status == 200 + assert response.headers.has("Content-Type") + + +@pytest.mark.asyncio +async def test_options_returns_allowed(django_asgi_app): + response = await fetch(django_asgi_app, "/cbv/", method="OPTIONS") + + assert response.status == 200 + allow = response.headers.get("Allow") + assert allow is not None + assert "GET" in allow + assert "POST" in allow diff --git a/packages/cli/tests/web-frameworks-test/django-async/src/worker.py b/packages/cli/tests/web-frameworks-test/django-async/src/worker.py new file mode 100644 index 0000000..c0b064c --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/src/worker.py @@ -0,0 +1,193 @@ +import importlib.util +from pathlib import Path + +import asgi +import pytest +from pyodide.webloop import WebLoop +from workers import Response, WorkerEntrypoint + +BASE_DIR = Path(__file__).parent +TEMPLATES_DIR = str(BASE_DIR / "templates") + + +async def _noop(*args): + pass + + +WebLoop.shutdown_asyncgens = _noop +WebLoop.shutdown_default_executor = _noop + +import django.conf # noqa: E402 + +django.conf.settings.configure( + DEBUG=False, + SECRET_KEY="test-secret-key-for-workers-py", + ROOT_URLCONF="django_app.urls", + ALLOWED_HOSTS=["*"], + INSTALLED_APPS=[ + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.auth", + "rest_framework", + "corsheaders", + ], + MIDDLEWARE=[ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_app.middleware.CustomAsyncMiddleware", + ], + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [TEMPLATES_DIR], + "APP_DIRS": False, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + ], + "builtins": ["django_app.templatetags.custom_tags"], + }, + } + ], + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + }, + SESSION_ENGINE="django.contrib.sessions.backends.signed_cookies", + AUTHENTICATION_BACKENDS=["django_app.auth_backend.InMemoryBackend"], + DATABASES={}, + TIME_ZONE="UTC", + USE_TZ=True, + DEFAULT_AUTO_FIELD="django.db.models.BigAutoField", + CORS_ALLOWED_ORIGINS=[ + "http://localhost:3000", + "https://example.com", + ], + CORS_ALLOW_CREDENTIALS=True, + CORS_ALLOWED_ORIGIN_REGEXES=[r"^https://.*\.example\.com$"], + REST_FRAMEWORK={ + "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"], + "DEFAULT_PARSER_CLASSES": [ + "rest_framework.parsers.JSONParser", + "rest_framework.parsers.FormParser", + "rest_framework.parsers.MultiPartParser", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "10/min", + "user": "100/min", + }, + }, +) + +import django # noqa: E402 + +django.setup() + +from django.core.handlers.asgi import ASGIHandler # noqa: E402 + +django_asgi_app = ASGIHandler() + + +class ResultCollector: + def __init__(self): + self.results = {} + + @staticmethod + def _key(item): + name = item.name + return name[len("test_") :] if name.startswith("test_") else name + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_makereport(self, item, call): + outcome = yield + report = outcome.get_result() + key = self._key(item) + + if report.when == "call": + if report.passed: + self.results[key] = {"status": "passed"} + elif report.skipped: + self.results[key] = { + "status": "skipped", + "reason": str(report.longrepr), + } + elif report.failed: + excinfo = call.excinfo + if excinfo is not None and excinfo.errisinstance(AssertionError): + self.results[key] = { + "status": "failed", + "error": str(excinfo.value), + } + else: + self.results[key] = { + "status": "error", + "error": f"{excinfo.typename}: {excinfo.value}" + if excinfo is not None + else "unknown error", + "traceback": report.longreprtext, + } + elif report.when in ("setup", "teardown") and report.skipped: + self.results[key] = { + "status": "skipped", + "reason": str(report.longrepr), + } + elif report.when in ("setup", "teardown") and report.failed: + self.results[key] = { + "status": "error", + "error": report.longreprtext, + "traceback": report.longreprtext, + } + + +class EnvPlugin: + def __init__(self, env): + self._env = env + + @pytest.fixture + def env(self): + return self._env + + +class DjangoAppPlugin: + @pytest.fixture + def django_asgi_app(self): + return django_asgi_app + + +class Default(WorkerEntrypoint): + async def fetch(self, request): + from urllib.parse import urlparse + + path = urlparse(request.url).path + + if path.startswith("/run-tests/"): + suite_name = path[len("/run-tests/") :] + return self._run_suite(suite_name) + if path == "/health": + return Response.json({"ok": True}) + + return await asgi.fetch(django_asgi_app, request, self.env, self.ctx) + + def _run_suite(self, suite_name): + module = f"test_{suite_name}" + if importlib.util.find_spec(module) is None: + return Response.json( + {"error": f"Unknown suite '{suite_name}' (no module '{module}')"}, + status=404, + ) + + collector = ResultCollector() + pytest.main( + ["--pyargs", module, "-p", "no:cacheprovider"], + plugins=[ + collector, + EnvPlugin(self.env), + DjangoAppPlugin(), + ], + ) + return Response.json(collector.results) diff --git a/packages/cli/tests/web-frameworks-test/django-async/wrangler.jsonc b/packages/cli/tests/web-frameworks-test/django-async/wrangler.jsonc new file mode 100644 index 0000000..6cb52de --- /dev/null +++ b/packages/cli/tests/web-frameworks-test/django-async/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "django-async-test", + "main": "src/worker.py", + "compatibility_date": "%COMPAT_DATE", + "compatibility_flags": ["python_workers"] +}