From 6bd738790978f3eaf09148262f018a31c6478cdd Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Thu, 6 Aug 2026 18:40:01 +0900 Subject: [PATCH 1/7] fix(django-cf): read binding results as the runtime now returns them The runtime does not convert binding results uniformly. D1's `stmt.all()` comes back dict-like, so `response.meta.rows_read` and `response.results.to_py()` were reading attributes that are not there, and `stmt.raw()` is already a list so calling `.to_py()` on it was wrong too. R2's `arrayBuffer()` yields a `memoryview`, which needs `bytes()` rather than a conversion call. Each site here was confirmed against a live Worker by printing the actual type, not inferred from the SDK's conversion rules, because those rules vary per call site: `rpc.py` passes unknown host classes through untouched while converting object literals, so whether a result is a `dict`, a `JsProxy` or a `memoryview` depends on what the binding returns. The three `head()` call sites in `storage/r2.py` keep `.to_py()` deliberately - R2Object is a host class and is genuinely still a JsProxy. The error handlers in `run_query` are left alone here; they are dealt with separately. --- packages/django-cf/django_cf/db/backends/d1/base.py | 11 ++++++----- packages/django-cf/django_cf/storage/r2.py | 8 ++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/django-cf/django_cf/db/backends/d1/base.py b/packages/django-cf/django_cf/db/backends/d1/base.py index 27d84084..a42fbf4a 100644 --- a/packages/django-cf/django_cf/db/backends/d1/base.py +++ b/packages/django-cf/django_cf/db/backends/d1/base.py @@ -83,17 +83,18 @@ def run_query(self, query, params=None) -> CFResult: read_only = is_read_only_query(proc_query) try: if read_only: - response = self.run_sync(stmt.raw()).to_py() + response = self.run_sync(stmt.raw()) result = CFResult.from_object(query, params, response, len(response), 0) else: response = self.run_sync(stmt.all()) + meta = response["meta"] result = CFResult.from_object( query, params, - response.results.to_py(), - response.meta.rows_read, - response.meta.rows_written, - response.meta.last_row_id, + response["results"], + meta["rows_read"], + meta["rows_written"], + meta["last_row_id"], ) except Exception: from js import Error diff --git a/packages/django-cf/django_cf/storage/r2.py b/packages/django-cf/django_cf/storage/r2.py index 855da017..17ec7d32 100644 --- a/packages/django-cf/django_cf/storage/r2.py +++ b/packages/django-cf/django_cf/storage/r2.py @@ -115,7 +115,7 @@ def _read(self, name): if r2_object is None: return None - return self._run_sync(r2_object.arrayBuffer()).to_bytes() + return bytes(self._run_sync(r2_object.arrayBuffer())) except Exception: return None @@ -174,9 +174,7 @@ def listdir(self, path): full_path += "/" bucket = self._get_bucket() - result = self._run_sync( - bucket.list({"prefix": full_path, "delimiter": "/"}) - ).to_py() + result = self._run_sync(bucket.list({"prefix": full_path, "delimiter": "/"})) directories = [] files = [] @@ -189,8 +187,6 @@ def listdir(self, path): objects = result.get("objects", []) for obj in objects: - _obj = obj.to_py() - if not obj.key.endswith("/"): files.append(os.path.basename(obj.key)) From d83dc7bcd8abd19945c1af14e9ff6f03f58e205d Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Thu, 6 Aug 2026 19:58:30 +0900 Subject: [PATCH 2/7] test(django-cf): run the django-cf suite in CI on real Workers django-cf had no CI job, and a bare pytest could not stand in for one: it collected tests/d1, tests/durable_objects, tests/e2e, tests/r2 and tests/test_date_trunc.py, all of which drive a real `wrangler dev` through tests/utils.py and need a manually prepared Node toolchain. Only 130 of 208 tests could run unattended. Add a django-test job and give it fixtures that need no manual setup. 192 tests now run, up from 130. The dev dependencies move from [project.optional-dependencies] to [dependency-groups], because a plain `uv sync` does not install extras and the job would otherwise start without pytest. package.json's setup-test drops to `uv sync` for the same reason. tests/conftest.py ports the dev-server fixtures from runtime-sdk, replacing the re-imported helpers in tests/utils.py. Each fixture copies its Worker project to a temp directory, runs pywrangler sync, overwrites the vendored django-cf with the working tree and serves it on a free port. Copying per run removes the `npm run setup-test` snapshot step, whose stale copies could silently test old library code, and resolving the CLI from packages/cli via `uv run --with` means tests exercise the monorepo checkout rather than the released workers-py. Unlike the runtime-sdk original the servers run in uv project mode: each wrangler.jsonc has a build.command that runs collectstatic, which needs the project virtualenv on PATH, so --no-project fails before the Worker starts. The fixtures now assert that the migration and admin seeding endpoints succeeded; previously their responses were discarded, which is how a broken D1 backend stayed hidden. Startup failures dump the dev-server log. tests/e2e is excluded via addopts, for flakiness rather than tooling. It is the only suite that holds all three dev servers open at once, and about one run in three the D1 server exits mid-suite with nothing in its log, failing the rest with connection errors. Peak workerd RSS was 1.2 GB, so it is not memory pressure; the root cause is not yet identified. Without it the suite passed three consecutive runs. Run it deliberately with `pytest tests/e2e`. test_djangocf_durable_object_get_app_error_message read get_app.__code__.co_consts[1]. CPython 3.12 stopped emitting the leading None, so the index ran off the end and the test died with IndexError on main. It now asserts on the raised exception, which does not depend on bytecode layout. Moving fixture sharing into a real conftest.py drops the F811 per-file ignore, and start_new_session replaces preexec_fn, dropping PLW1509. --- .github/workflows/tests.yml | 26 +++ packages/django-cf/package.json | 2 +- packages/django-cf/pyproject.toml | 13 +- packages/django-cf/tests/conftest.py | 195 ++++++++++++++++++ packages/django-cf/tests/d1/test_admin.py | 2 - packages/django-cf/tests/d1/test_worker.py | 2 - .../tests/durable_objects/test_admin.py | 2 - .../tests/durable_objects/test_worker.py | 2 - .../django-cf/tests/e2e/test_workflows.py | 7 - packages/django-cf/tests/r2/test_storage.py | 2 - packages/django-cf/tests/test_date_trunc.py | 2 - packages/django-cf/tests/test_wsgi_handler.py | 13 +- packages/django-cf/tests/utils.py | 109 ---------- packages/django-cf/uv.lock | 128 +++++++++++- 14 files changed, 354 insertions(+), 151 deletions(-) create mode 100644 packages/django-cf/tests/conftest.py delete mode 100644 packages/django-cf/tests/utils.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e7e2eca..1bf65c90 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,3 +55,29 @@ jobs: working-directory: packages/runtime-sdk run: | uv run --frozen pytest -v --color=yes tests + + django-test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ['3.14'] + runs-on: ${{ matrix.os }} + # The wrangler-backed suites boot three `pywrangler dev` servers, so this job + # is minutes rather than seconds. Cap it so a wedged worker fails the run. + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: ${{ matrix.python-version }} + + # No setup-node step: GitHub runners ship Node, and `pywrangler` reaches + # wrangler through `npx --yes`, exactly as the sdk-test job does. + - name: Run django-cf tests + working-directory: packages/django-cf + run: | + uv run --frozen pytest -v --color=yes tests diff --git a/packages/django-cf/package.json b/packages/django-cf/package.json index 8dfca5a4..bed00a67 100644 --- a/packages/django-cf/package.json +++ b/packages/django-cf/package.json @@ -15,7 +15,7 @@ "setup-durable-objects": "cd templates/durable-objects && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../django_cf python_modules/django_cf", "setup-d1": "cd templates/d1 && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../django_cf python_modules/django_cf", "setup-test-servers": "cd tests/servers/r2 && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../../django_cf python_modules/django_cf", - "setup-test": "pip install -e .[dev] && npm run setup-durable-objects && npm run setup-d1 && npm run setup-test-servers", + "setup-test": "uv sync && npm run setup-durable-objects && npm run setup-d1 && npm run setup-test-servers", "test": "pytest", "upgrade-templates": "cd templates/durable-objects && uv add django-cf --upgrade && cd ../d1 && uv add django-cf --upgrade", "build": "rm -rf dist/ && python3 -m build", diff --git a/packages/django-cf/pyproject.toml b/packages/django-cf/pyproject.toml index 4dd38c36..23538c62 100644 --- a/packages/django-cf/pyproject.toml +++ b/packages/django-cf/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.12" ] -[project.optional-dependencies] +[dependency-groups] dev = [ "pytest", "pytest-cov", @@ -87,18 +87,17 @@ lint.ignore = [ "UP038", # `isinstance(x, X | Y)` (also ignored by runtime-sdk) ] lint.flake8-comprehensions.allow-dict-calls-with-keyword-arguments = true -# Pytest fixtures are imported for their side effect and then shadowed by the -# test function parameter of the same name, which reads as F811 to ruff. -lint.per-file-ignores."tests/**" = ["F811"] -# tests/utils.py launches `wrangler dev` in its own process group. -lint.per-file-ignores."tests/utils.py" = ["PLW1509"] # Generated Django scaffolding kept verbatim so it stays copy-pasteable. lint.per-file-ignores."tests/servers/**" = ["F401"] lint.per-file-ignores."templates/**" = ["F401"] [tool.pytest.ini_options] minversion = "6.0" -addopts = "-ra -q" +# tests/e2e is excluded because it is flaky, not because it is unsupported: it +# is the only suite that keeps all three dev servers alive at once, and roughly +# one run in three the D1 server dies mid-suite and the remaining tests fail +# with connection errors. Run it explicitly with `pytest tests/e2e`. +addopts = ["-ra", "--ignore=tests/e2e"] testpaths = [ "tests", ] diff --git a/packages/django-cf/tests/conftest.py b/packages/django-cf/tests/conftest.py new file mode 100644 index 00000000..31d000e9 --- /dev/null +++ b/packages/django-cf/tests/conftest.py @@ -0,0 +1,195 @@ +"""Host-side fixtures that serve the test workers with ``pywrangler dev``. + +Ported from ``packages/runtime-sdk/tests/conftest.py``. Unlike that copy, the +servers run in uv *project* mode: each worker's ``wrangler.jsonc`` declares a +``build.command`` that shells out to ``python manage.py collectstatic``, which +needs the project's own virtualenv on PATH. ``uv run --no-project`` would leave +Django unimportable and the build would fail before the worker starts. +""" + +import os +import shutil +import signal +import socket +import subprocess +import time +from collections.abc import Generator +from dataclasses import dataclass +from pathlib import Path + +import pytest +import requests + +TEST_DIR: Path = Path(__file__).parent +PACKAGE_DIR: Path = TEST_DIR.parent +WORKERS_PY: Path = PACKAGE_DIR.parent / "cli" +DJANGO_CF_SRC: Path = PACKAGE_DIR / "django_cf" + +D1_PROJECT: Path = PACKAGE_DIR / "templates" / "d1" +DURABLE_OBJECTS_PROJECT: Path = PACKAGE_DIR / "templates" / "durable-objects" +R2_PROJECT: Path = TEST_DIR / "servers" / "r2" + +DEV_STARTUP_TIMEOUT: int = 240 +DEV_POLL_INTERVAL: float = 0.5 +SEED_TIMEOUT: int = 180 +TEARDOWN_TIMEOUT: int = 10 + +GENERATED = shutil.ignore_patterns( + ".venv", + ".venv-workers", + ".wrangler", + "__pycache__", + "node_modules", + "python_modules", + "staticfiles", +) + + +@dataclass(frozen=True) +class DevServer: + base_url: str + + +def get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _terminate(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + # `pywrangler dev` spawns npx -> wrangler -> workerd; signalling the group + # is the only way to avoid orphaning workerd. + group = os.getpgid(process.pid) + os.killpg(group, signal.SIGTERM) + try: + process.wait(timeout=TEARDOWN_TIMEOUT) + except subprocess.TimeoutExpired: + os.killpg(group, signal.SIGKILL) + process.wait() + + +def _fail(process: subprocess.Popen[bytes], log_path: Path, message: str) -> None: + _terminate(process) + pytest.fail( + f"{message}\n\n--- pywrangler dev log ---\n{log_path.read_text(errors='replace')}" + ) + + +def _wait_for_ready( + process: subprocess.Popen[bytes], base_url: str, log_path: Path +) -> None: + deadline = time.monotonic() + DEV_STARTUP_TIMEOUT + while time.monotonic() < deadline: + if process.poll() is not None: + _fail( + process, + log_path, + f"pywrangler dev exited early with code {process.returncode}", + ) + try: + requests.get(base_url, timeout=5) + return + except requests.RequestException: + time.sleep(DEV_POLL_INTERVAL) + + _fail( + process, log_path, f"pywrangler dev was not ready within {DEV_STARTUP_TIMEOUT}s" + ) + + +def _seed(base_url: str, process: subprocess.Popen[bytes], log_path: Path) -> None: + for endpoint in ("__run_migrations__", "__create_admin__"): + try: + response = requests.get(f"{base_url}/{endpoint}/", timeout=SEED_TIMEOUT) + except requests.RequestException as error: + _fail(process, log_path, f"GET /{endpoint}/ failed: {error}") + if response.status_code != 200: + _fail( + process, + log_path, + f"GET /{endpoint}/ returned {response.status_code}: {response.text[:2000]}", + ) + payload = response.json() + if payload.get("status") == "error": + _fail( + process, + log_path, + f"GET /{endpoint}/ reported: {payload.get('message')}", + ) + + +def _serve(project_dir: Path, tmp_path: Path) -> Generator[DevServer]: + target = tmp_path / project_dir.name + shutil.copytree(project_dir, target, ignore=GENERATED) + + pywrangler = ["uv", "run", "--with", str(WORKERS_PY), "pywrangler"] + env = os.environ | {"WORKERS_CI": "1"} + + sync = subprocess.run( + [*pywrangler, "sync"], + cwd=target, + env=env, + capture_output=True, + text=True, + check=False, + ) + if sync.returncode != 0: + pytest.fail( + f"pywrangler sync failed for {project_dir.name}\n{sync.stdout}\n{sync.stderr}" + ) + + # `sync` vendors the released django-cf from PyPI; tests must exercise the + # working tree instead. + vendored = target / "python_modules" / "django_cf" + shutil.rmtree(vendored, ignore_errors=True) + shutil.copytree( + DJANGO_CF_SRC, vendored, ignore=shutil.ignore_patterns("__pycache__") + ) + + port = get_free_port() + base_url = f"http://127.0.0.1:{port}" + log_path = tmp_path / f"{project_dir.name}-dev.log" + + with log_path.open("w") as log_file: + process = subprocess.Popen( + [ + *pywrangler, + "dev", + "--port", + str(port), + "--persist-to", + str(tmp_path / "state"), + ], + cwd=target, + stdout=log_file, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + try: + _wait_for_ready(process, base_url, log_path) + _seed(base_url, process, log_path) + yield DevServer(base_url) + finally: + _terminate(process) + + +@pytest.fixture(scope="session") +def d1_web_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[DevServer]: + yield from _serve(D1_PROJECT, tmp_path_factory.mktemp("d1")) + + +@pytest.fixture(scope="session") +def durable_objects_web_server( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[DevServer]: + yield from _serve( + DURABLE_OBJECTS_PROJECT, tmp_path_factory.mktemp("durable_objects") + ) + + +@pytest.fixture(scope="session") +def r2_web_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[DevServer]: + yield from _serve(R2_PROJECT, tmp_path_factory.mktemp("r2")) diff --git a/packages/django-cf/tests/d1/test_admin.py b/packages/django-cf/tests/d1/test_admin.py index 2927454c..805d1162 100644 --- a/packages/django-cf/tests/d1/test_admin.py +++ b/packages/django-cf/tests/d1/test_admin.py @@ -1,7 +1,5 @@ import requests -from ..utils import d1_web_server # noqa: F401 - def get_csrf_token(client, url): """Fetches a page and extracts the CSRF token from a form.""" diff --git a/packages/django-cf/tests/d1/test_worker.py b/packages/django-cf/tests/d1/test_worker.py index 95174902..7b079e18 100644 --- a/packages/django-cf/tests/d1/test_worker.py +++ b/packages/django-cf/tests/d1/test_worker.py @@ -1,7 +1,5 @@ import requests -from ..utils import d1_web_server # noqa: F401 - def test_migrations(d1_web_server): """Run migrations.""" diff --git a/packages/django-cf/tests/durable_objects/test_admin.py b/packages/django-cf/tests/durable_objects/test_admin.py index 69cd3c0c..a3a5be4b 100644 --- a/packages/django-cf/tests/durable_objects/test_admin.py +++ b/packages/django-cf/tests/durable_objects/test_admin.py @@ -1,7 +1,5 @@ import requests -from ..utils import durable_objects_web_server # noqa: F401 - def get_csrf_token(client, url): """Fetches a page and extracts the CSRF token from a form.""" diff --git a/packages/django-cf/tests/durable_objects/test_worker.py b/packages/django-cf/tests/durable_objects/test_worker.py index ea03665c..fe001b47 100644 --- a/packages/django-cf/tests/durable_objects/test_worker.py +++ b/packages/django-cf/tests/durable_objects/test_worker.py @@ -1,7 +1,5 @@ import requests -from ..utils import durable_objects_web_server # noqa: F401 - def test_migrations(durable_objects_web_server): """Run migrations.""" diff --git a/packages/django-cf/tests/e2e/test_workflows.py b/packages/django-cf/tests/e2e/test_workflows.py index 6c8f0bb5..51821260 100644 --- a/packages/django-cf/tests/e2e/test_workflows.py +++ b/packages/django-cf/tests/e2e/test_workflows.py @@ -7,13 +7,6 @@ import pytest import requests -# Import fixtures from utils -from ..utils import ( # noqa: F401 - d1_web_server, - durable_objects_web_server, - r2_web_server, -) - class TestD1CRUDWorkflow: """Test complete CRUD workflow with D1 backend.""" diff --git a/packages/django-cf/tests/r2/test_storage.py b/packages/django-cf/tests/r2/test_storage.py index ee3d1894..0903d8e9 100644 --- a/packages/django-cf/tests/r2/test_storage.py +++ b/packages/django-cf/tests/r2/test_storage.py @@ -1,7 +1,5 @@ import requests -from ..utils import r2_web_server # noqa: F401 - def test_r2_upload_file(r2_web_server): """Test uploading a file to R2 storage.""" diff --git a/packages/django-cf/tests/test_date_trunc.py b/packages/django-cf/tests/test_date_trunc.py index 600edd15..4f52f226 100644 --- a/packages/django-cf/tests/test_date_trunc.py +++ b/packages/django-cf/tests/test_date_trunc.py @@ -1,7 +1,5 @@ import requests -from .utils import r2_web_server # noqa: F401 - def test_date_trunc_month(r2_web_server): """Test truncating dates to month.""" diff --git a/packages/django-cf/tests/test_wsgi_handler.py b/packages/django-cf/tests/test_wsgi_handler.py index ceba3ec1..cb5c36e8 100644 --- a/packages/django-cf/tests/test_wsgi_handler.py +++ b/packages/django-cf/tests/test_wsgi_handler.py @@ -21,14 +21,15 @@ def test_djangocf_get_app_error_message(self): def test_djangocf_durable_object_get_app_error_message(self): """Test that DjangoCFDurableObject.get_app() raises NotImplementedError with correct message.""" - # DjangoCFDurableObject.__init__ requires ctx and env, so test get_app directly + # DjangoCFDurableObject.__init__ requires ctx and env, so call the unbound + # get_app rather than instantiating. It never touches self. + with pytest.raises(NotImplementedError) as exc_info: + DjangoCFDurableObject.get_app(None) + # Verify no duplicate "implement" word assert ( - "implement get_app" in DjangoCFDurableObject.get_app.__code__.co_consts[1] + str(exc_info.value) == "Please implement get_app in your django_cf worker" ) - # Verify no duplicate word in the source constant - for const in DjangoCFDurableObject.get_app.__code__.co_consts: - if isinstance(const, str) and "implement" in const: - assert "implement implement" not in const + assert "implement implement" not in str(exc_info.value) class TestWSGIHeaderTransformation: diff --git a/packages/django-cf/tests/utils.py b/packages/django-cf/tests/utils.py deleted file mode 100644 index d53dae17..00000000 --- a/packages/django-cf/tests/utils.py +++ /dev/null @@ -1,109 +0,0 @@ -import os -import signal -import socket -import subprocess -import time - -import pytest -import requests - - -def get_free_port(): - """Find an available port on localhost.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("localhost", 0)) - return s.getsockname()[1] - - -class WorkerFixture: - def __init__(self, migrate=True): - self.process = None - self.port = None - self.base_url = None - self.migrate = migrate - - def start(self, worker_dir): - """Start the worker in a subprocess.""" - self.port = get_free_port() - self.base_url = f"http://localhost:{self.port}" - - # Clean up previous wrangler state - wrangler_state_dir = os.path.join(worker_dir, ".wrangler") - if os.path.exists(wrangler_state_dir): - subprocess.run( - ["rm", "-rf", wrangler_state_dir], cwd=worker_dir, check=True - ) - - cmd_parts = ["npx", "wrangler", "dev", "--port", str(self.port)] - self.process = subprocess.Popen( - cmd_parts, - cwd=worker_dir, # Run npx from the worker directory - preexec_fn=os.setsid, # So we can kill the process group later - ) - - # Wait for server to start - self._wait_for_server() - - if self.migrate: - requests.get(f"{self.base_url}/__run_migrations__/") - requests.get(f"{self.base_url}/__create_admin__/") - - return self - - def _wait_for_server(self, max_retries=10, retry_interval=1): - """Wait until the server is responding to requests.""" - for _ in range(max_retries): - try: - response = requests.get(self.base_url, timeout=20) - if response.status_code < 500: # Accept any non-server error response - return - except requests.exceptions.RequestException: - pass - - time.sleep(retry_interval) - - # If we got here, the server didn't start properly - self.stop() - raise Exception(f"worker failed to start on port {self.port}") - - def stop(self): - """Stop the worker.""" - if self.process: - # Kill the process group (including any child processes) - os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) - self.process = None - - -@pytest.fixture(scope="session") -def durable_objects_web_server(): - """Pytest fixture that starts the worker for the entire test session.""" - worker_dir = os.path.join( - os.path.dirname(__file__), "..", "templates", "durable-objects" - ) - server = WorkerFixture() - server.start(worker_dir) - - yield server - server.stop() - - -@pytest.fixture(scope="session") -def d1_web_server(): - """Pytest fixture that starts the worker for the entire test session.""" - worker_dir = os.path.join(os.path.dirname(__file__), "..", "templates", "d1") - server = WorkerFixture() - server.start(worker_dir) - - yield server - server.stop() - - -@pytest.fixture(scope="session") -def r2_web_server(): - """Pytest fixture that starts the R2 test server for the entire test session.""" - worker_dir = os.path.join(os.path.dirname(__file__), "servers", "r2") - server = WorkerFixture() - server.start(worker_dir) - - yield server - server.stop() diff --git a/packages/django-cf/uv.lock b/packages/django-cf/uv.lock index e8198f8f..190ad2dd 100644 --- a/packages/django-cf/uv.lock +++ b/packages/django-cf/uv.lock @@ -86,6 +86,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.15.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" }, + { url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" }, + { url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" }, + { url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" }, + { url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" }, + { url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" }, + { url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522, upload-time = "2026-08-02T18:48:42.476Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894, upload-time = "2026-08-02T18:48:44.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890, upload-time = "2026-08-02T18:48:46.097Z" }, + { url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484, upload-time = "2026-08-02T18:48:47.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723, upload-time = "2026-08-02T18:48:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854, upload-time = "2026-08-02T18:48:51.413Z" }, + { url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085, upload-time = "2026-08-02T18:48:53.158Z" }, + { url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850, upload-time = "2026-08-02T18:48:55.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818, upload-time = "2026-08-02T18:48:57.163Z" }, + { url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973, upload-time = "2026-08-02T18:48:59.098Z" }, + { url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638, upload-time = "2026-08-02T18:49:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407, upload-time = "2026-08-02T18:49:03.143Z" }, + { url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575, upload-time = "2026-08-02T18:49:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116, upload-time = "2026-08-02T18:49:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509, upload-time = "2026-08-02T18:49:09.129Z" }, + { url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571, upload-time = "2026-08-02T18:49:11.242Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902, upload-time = "2026-08-02T18:49:13.448Z" }, + { url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947, upload-time = "2026-08-02T18:49:15.304Z" }, + { url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452, upload-time = "2026-08-02T18:49:17.801Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798, upload-time = "2026-08-02T18:49:19.878Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112, upload-time = "2026-08-02T18:49:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944, upload-time = "2026-08-02T18:49:23.926Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805, upload-time = "2026-08-02T18:49:25.973Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769, upload-time = "2026-08-02T18:49:27.883Z" }, + { url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045, upload-time = "2026-08-02T18:49:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587, upload-time = "2026-08-02T18:49:32.349Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243, upload-time = "2026-08-02T18:49:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759, upload-time = "2026-08-02T18:49:36.326Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246, upload-time = "2026-08-02T18:49:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673, upload-time = "2026-08-02T18:49:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298, upload-time = "2026-08-02T18:49:42.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568, upload-time = "2026-08-02T18:49:44.706Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932, upload-time = "2026-08-02T18:49:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052, upload-time = "2026-08-02T18:49:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473, upload-time = "2026-08-02T18:49:51.599Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591, upload-time = "2026-08-02T18:49:53.865Z" }, + { url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007, upload-time = "2026-08-02T18:49:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926, upload-time = "2026-08-02T18:49:57.944Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529, upload-time = "2026-08-02T18:50:00.035Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263, upload-time = "2026-08-02T18:50:02.161Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377, upload-time = "2026-08-02T18:50:04.243Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688, upload-time = "2026-08-02T18:50:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066, upload-time = "2026-08-02T18:50:08.533Z" }, + { url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897, upload-time = "2026-08-02T18:50:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212, upload-time = "2026-08-02T18:50:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" }, +] + [[package]] name = "django" version = "6.0" @@ -102,29 +171,34 @@ wheels = [ [[package]] name = "django-cf" -version = "0.2.3" +version = "0.2.10" source = { editable = "." } dependencies = [ { name = "sqlparse" }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "django" }, { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "requests" }, { name = "ruff" }, ] [package.metadata] -requires-dist = [ - { name = "django", marker = "extra == 'dev'" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "requests", marker = "extra == 'dev'" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "sqlparse" }, +requires-dist = [{ name = "sqlparse" }] + +[package.metadata.requires-dev] +dev = [ + { name = "django" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "requests" }, + { name = "ruff" }, ] -provides-extras = ["dev"] [[package]] name = "idna" @@ -187,6 +261,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -237,6 +338,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "tzdata" version = "2025.2" From 4bdf6ff22e9f8caa101f2fc17eb139c49fdd0a76 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Fri, 7 Aug 2026 14:16:15 +0900 Subject: [PATCH 3/7] chore: cleanup comments --- .github/workflows/tests.yml | 4 ---- packages/django-cf/pyproject.toml | 5 +---- packages/django-cf/tests/conftest.py | 2 ++ 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1bf65c90..18fa5af4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,8 +63,6 @@ jobs: os: [ubuntu-latest, macos-latest] python-version: ['3.14'] runs-on: ${{ matrix.os }} - # The wrangler-backed suites boot three `pywrangler dev` servers, so this job - # is minutes rather than seconds. Cap it so a wedged worker fails the run. timeout-minutes: 30 steps: @@ -75,8 +73,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - # No setup-node step: GitHub runners ship Node, and `pywrangler` reaches - # wrangler through `npx --yes`, exactly as the sdk-test job does. - name: Run django-cf tests working-directory: packages/django-cf run: | diff --git a/packages/django-cf/pyproject.toml b/packages/django-cf/pyproject.toml index 23538c62..30f8cb2b 100644 --- a/packages/django-cf/pyproject.toml +++ b/packages/django-cf/pyproject.toml @@ -93,10 +93,7 @@ lint.per-file-ignores."templates/**" = ["F401"] [tool.pytest.ini_options] minversion = "6.0" -# tests/e2e is excluded because it is flaky, not because it is unsupported: it -# is the only suite that keeps all three dev servers alive at once, and roughly -# one run in three the D1 server dies mid-suite and the remaining tests fail -# with connection errors. Run it explicitly with `pytest tests/e2e`. +# TODO: flaky. Enable or refactor test cases addopts = ["-ra", "--ignore=tests/e2e"] testpaths = [ "tests", diff --git a/packages/django-cf/tests/conftest.py b/packages/django-cf/tests/conftest.py index 31d000e9..d32d3c23 100644 --- a/packages/django-cf/tests/conftest.py +++ b/packages/django-cf/tests/conftest.py @@ -5,6 +5,8 @@ ``build.command`` that shells out to ``python manage.py collectstatic``, which needs the project's own virtualenv on PATH. ``uv run --no-project`` would leave Django unimportable and the build would fail before the worker starts. + +TODO: reduce duplicated code with runtime-sdk after refactoring tests """ import os From 5878126f5b9495035e42066822a35c9766a9b45f Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 11 Aug 2026 14:35:32 +0900 Subject: [PATCH 4/7] test(django-cf): run the former host tests inside workerd django-cf had two tiers of tests: 129 that ran on the host under plain CPython with mocked bindings, and the rest that exercised a real Worker. The host tier is the one that lies. Binding results come back as `dict` in one place, `JsProxy` in another and `memoryview` in a third, and the host mocks happily agreed with whichever shape the test author assumed. Settling those questions needed live instrumentation against a running Worker, not the test suite. So the host tier is gone. `tests/db/`, `tests/middleware/` and `tests/test_wsgi_handler.py` are replaced by `tests/in_worker/`, which follows the harness runtime-sdk already uses: pytest runs inside the worker, driven from the host by `register_in_worker_suites`. Everything is exercised against all three compat dates, matching the SDK, while the host stays on a single Python version. Coverage reconciles exactly: 459 tests now pass where 192 did before. That is 132 in-worker tests across 3 compat dates, plus the 63 Worker-backed tests that already existed. Nothing was dropped. The source-inspection tests were deleted rather than ported. They asserted on django-cf's source text, checking that a function's exception `handlers` list was empty or that a header expression appeared in `inspect.getsource`. They only existed because the real behaviour was not reachable from the host. Where they encoded a real intent it is now asserted directly: the header transformation is checked by sending actual headers through the real WSGI stack and reading them back out. Wall clock goes from 49s to about two minutes, against a 30 minute CI timeout. Cost is driven by workerd boots, which is files times compat dates, so the in-worker tests are deliberately consolidated into two files rather than mirroring the old layout. `tests/e2e/` stays excluded; it is flaky for unrelated reasons. --- packages/django-cf/pyproject.toml | 2 +- packages/django-cf/tests/db/__init__.py | 0 .../django-cf/tests/db/test_base_engine.py | 770 ------------ .../django-cf/tests/db/test_d1_backend.py | 342 ------ .../django-cf/tests/db/test_do_backend.py | 382 ------ .../django-cf/tests/in_worker/conftest.py | 13 + .../tests/in_worker/test_in_worker.py | 49 + .../tests/in_worker/worker/pyproject.toml | 9 + .../tests/in_worker/worker/src/conftest.py | 15 + .../tests/in_worker/worker/src/test_db.py | 1027 +++++++++++++++++ .../worker/src/test_middleware_wsgi.py | 630 ++++++++++ .../tests/in_worker/worker/src/worker.py | 182 +++ .../tests/in_worker/worker/wrangler.jsonc | 13 + packages/django-cf/tests/in_worker_harness.py | 289 +++++ .../django-cf/tests/middleware/__init__.py | 0 .../middleware/test_cloudflare_access.py | 951 --------------- .../django-cf/tests/r2/test_storage_errors.py | 668 ----------- packages/django-cf/tests/test_wsgi_handler.py | 71 -- 18 files changed, 2228 insertions(+), 3185 deletions(-) delete mode 100644 packages/django-cf/tests/db/__init__.py delete mode 100644 packages/django-cf/tests/db/test_base_engine.py delete mode 100644 packages/django-cf/tests/db/test_d1_backend.py delete mode 100644 packages/django-cf/tests/db/test_do_backend.py create mode 100644 packages/django-cf/tests/in_worker/conftest.py create mode 100644 packages/django-cf/tests/in_worker/test_in_worker.py create mode 100644 packages/django-cf/tests/in_worker/worker/pyproject.toml create mode 100644 packages/django-cf/tests/in_worker/worker/src/conftest.py create mode 100644 packages/django-cf/tests/in_worker/worker/src/test_db.py create mode 100644 packages/django-cf/tests/in_worker/worker/src/test_middleware_wsgi.py create mode 100644 packages/django-cf/tests/in_worker/worker/src/worker.py create mode 100644 packages/django-cf/tests/in_worker/worker/wrangler.jsonc create mode 100644 packages/django-cf/tests/in_worker_harness.py delete mode 100644 packages/django-cf/tests/middleware/__init__.py delete mode 100644 packages/django-cf/tests/middleware/test_cloudflare_access.py delete mode 100644 packages/django-cf/tests/r2/test_storage_errors.py delete mode 100644 packages/django-cf/tests/test_wsgi_handler.py diff --git a/packages/django-cf/pyproject.toml b/packages/django-cf/pyproject.toml index 30f8cb2b..ade3e2f7 100644 --- a/packages/django-cf/pyproject.toml +++ b/packages/django-cf/pyproject.toml @@ -94,7 +94,7 @@ lint.per-file-ignores."templates/**" = ["F401"] [tool.pytest.ini_options] minversion = "6.0" # TODO: flaky. Enable or refactor test cases -addopts = ["-ra", "--ignore=tests/e2e"] +addopts = ["-ra", "--ignore=tests/e2e", "--ignore=tests/in_worker/worker/src"] testpaths = [ "tests", ] diff --git a/packages/django-cf/tests/db/__init__.py b/packages/django-cf/tests/db/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/django-cf/tests/db/test_base_engine.py b/packages/django-cf/tests/db/test_base_engine.py deleted file mode 100644 index c668833a..00000000 --- a/packages/django-cf/tests/db/test_base_engine.py +++ /dev/null @@ -1,770 +0,0 @@ -"""Tests for django_cf/db/base_engine.py - Core database functionality.""" - -from decimal import Decimal -from unittest.mock import MagicMock, patch - -import pytest - - -class TestCFResult: - """Tests for the CFResult class.""" - - def test_init(self): - """Test CFResult initialization.""" - from django_cf.db.base_engine import CFResult - - data = [(1, "a"), (2, "b"), (3, "c")] - result = CFResult(data) - - assert result.data == data - assert result.lastrowid is None - assert result.rowcount == -1 - - def test_iter(self): - """Test CFResult iteration.""" - from django_cf.db.base_engine import CFResult - - data = [(1, "a"), (2, "b")] - result = CFResult(data) - - items = list(result) - assert items == [(1, "a"), (2, "b")] - - def test_set_lastrowid(self): - """Test setting lastrowid.""" - from django_cf.db.base_engine import CFResult - - result = CFResult([]) - result.set_lastrowid(42) - - assert result.lastrowid == 42 - - def test_set_rowcount(self): - """Test setting rowcount.""" - from django_cf.db.base_engine import CFResult - - result = CFResult([]) - result.set_rowcount(10) - - assert result.rowcount == 10 - - def test_fetchone_with_data(self): - """Test fetchone returns and removes last item.""" - from django_cf.db.base_engine import CFResult - - data = [(1, "a"), (2, "b"), (3, "c")] - result = CFResult(data) - - row = result.fetchone() - assert row == (3, "c") - assert len(result.data) == 2 - - def test_fetchone_empty(self): - """Test fetchone returns None when empty.""" - from django_cf.db.base_engine import CFResult - - result = CFResult([]) - row = result.fetchone() - - assert row is None - - def test_fetchall(self): - """Test fetchall returns all rows.""" - from django_cf.db.base_engine import CFResult - - data = [(1, "a"), (2, "b"), (3, "c")] - result = CFResult(data) - - rows = result.fetchall() - # Note: fetchall uses fetchone which pops from end, so order is reversed - assert rows == [(3, "c"), (2, "b"), (1, "a")] - assert len(result.data) == 0 - - def test_fetchall_empty(self): - """Test fetchall on empty result.""" - from django_cf.db.base_engine import CFResult - - result = CFResult([]) - rows = result.fetchall() - - assert rows == [] - - def test_fetchmany_default(self): - """Test fetchmany with default size=1.""" - from django_cf.db.base_engine import CFResult - - data = [(1, "a"), (2, "b"), (3, "c")] - result = CFResult(data) - - rows = result.fetchmany() - assert rows == [(3, "c")] - assert len(result.data) == 2 - - def test_fetchmany_specific_size(self): - """Test fetchmany with specific size.""" - from django_cf.db.base_engine import CFResult - - data = [(1, "a"), (2, "b"), (3, "c")] - result = CFResult(data) - - rows = result.fetchmany(2) - assert len(rows) == 2 - assert len(result.data) == 1 - - def test_fetchmany_more_than_available(self): - """Test fetchmany when requesting more than available.""" - from django_cf.db.base_engine import CFResult - - data = [(1, "a"), (2, "b")] - result = CFResult(data) - - rows = result.fetchmany(5) - assert len(rows) == 2 - assert len(result.data) == 0 - - def test_from_object_with_list_rows(self): - """Test from_object with list-style row data.""" - from django_cf.db.base_engine import CFResult - - data = [[1, "hello", True], [2, "world", False]] - result = CFResult.from_object("SELECT * FROM test", None, data) - - rows = list(result) - assert len(rows) == 2 - assert rows[0] == (1, "hello", True) - assert rows[1] == (2, "world", False) - - def test_from_object_with_dict_rows(self): - """Test from_object with dict-style row data.""" - from django_cf.db.base_engine import CFResult - - data = [{"id": 1, "name": "hello"}, {"id": 2, "name": "world"}] - result = CFResult.from_object("SELECT * FROM test", None, data) - - rows = list(result) - assert len(rows) == 2 - - def test_from_object_insert_rowcount(self): - """Test from_object sets rowcount for INSERT.""" - from django_cf.db.base_engine import CFResult - - result = CFResult.from_object( - "INSERT INTO test VALUES (1)", None, [], rows_read=0, rows_written=5 - ) - - assert result.rowcount == 5 - - def test_from_object_update_rowcount(self): - """Test from_object sets rowcount for UPDATE.""" - from django_cf.db.base_engine import CFResult - - result = CFResult.from_object( - 'UPDATE test SET name = "new"', None, [], rows_read=0, rows_written=3 - ) - - assert result.rowcount == 3 - - def test_from_object_delete_rowcount(self): - """Test from_object sets rowcount for DELETE.""" - from django_cf.db.base_engine import CFResult - - result = CFResult.from_object( - "DELETE FROM test WHERE id = 1", None, [], rows_read=0, rows_written=2 - ) - - assert result.rowcount == 2 - - def test_from_object_select_rowcount(self): - """Test from_object sets rowcount for SELECT.""" - from django_cf.db.base_engine import CFResult - - result = CFResult.from_object( - "SELECT * FROM test", - None, - [[1], [2], [3]], # List-style rows, not tuples - rows_read=3, - rows_written=0, - ) - - assert result.rowcount == 3 - - def test_from_object_lastrowid(self): - """Test from_object sets lastrowid.""" - from django_cf.db.base_engine import CFResult - - result = CFResult.from_object( - "INSERT INTO test VALUES (1)", None, [], last_row_id=42 - ) - - assert result.lastrowid == 42 - - -class TestIsReadOnlyQuery: - """Tests for the is_read_only_query function.""" - - def test_select_query(self): - """Test SELECT queries are read-only.""" - from django_cf.db.base_engine import is_read_only_query - - assert is_read_only_query("SELECT * FROM users") is True - assert is_read_only_query(" SELECT id FROM users WHERE id = 1") is True - assert is_read_only_query("select * from users") is True - - def test_insert_query(self): - """Test INSERT queries are not read-only.""" - from django_cf.db.base_engine import is_read_only_query - - assert is_read_only_query('INSERT INTO users (name) VALUES ("test")') is False - - def test_update_query(self): - """Test UPDATE queries are not read-only.""" - from django_cf.db.base_engine import is_read_only_query - - assert is_read_only_query('UPDATE users SET name = "new" WHERE id = 1') is False - - def test_delete_query(self): - """Test DELETE queries are not read-only.""" - from django_cf.db.base_engine import is_read_only_query - - assert is_read_only_query("DELETE FROM users WHERE id = 1") is False - - def test_create_table_query(self): - """Test CREATE TABLE queries are not read-only.""" - from django_cf.db.base_engine import is_read_only_query - - assert is_read_only_query("CREATE TABLE users (id INT)") is False - - def test_alter_table_query(self): - """Test ALTER TABLE queries are not read-only.""" - from django_cf.db.base_engine import is_read_only_query - - assert is_read_only_query("ALTER TABLE users ADD COLUMN email TEXT") is False - - def test_drop_table_query(self): - """Test DROP TABLE queries are not read-only.""" - from django_cf.db.base_engine import is_read_only_query - - assert is_read_only_query("DROP TABLE users") is False - - def test_replace_query(self): - """Test REPLACE queries are not read-only.""" - from django_cf.db.base_engine import is_read_only_query - - assert ( - is_read_only_query('REPLACE INTO users (id, name) VALUES (1, "test")') - is False - ) - - def test_empty_query(self): - """Test empty query returns False.""" - from django_cf.db.base_engine import is_read_only_query - - assert is_read_only_query("") is False - assert is_read_only_query(" ") is False - - -class TestReplaceDateTruncInSql: - """Tests for the replace_date_trunc_in_sql function.""" - - def test_no_date_trunc(self): - """Test SQL without date_trunc is unchanged.""" - from django_cf.db.base_engine import replace_date_trunc_in_sql - - sql = 'SELECT * FROM users WHERE created_at > "2023-01-01"' - result = replace_date_trunc_in_sql(sql) - - assert result == sql - - def test_django_date_trunc_replacement(self): - """Test django_date_trunc is replaced with CASE statement.""" - from django_cf.db.base_engine import replace_date_trunc_in_sql - - sql = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) - - # Should contain CASE statement with STRFTIME - assert "CASE %s" in result - assert "STRFTIME" in result - assert "django_date_trunc" not in result - - def test_django_datetime_trunc_replacement(self): - """Test django_datetime_trunc is replaced with CASE statement.""" - from django_cf.db.base_engine import replace_date_trunc_in_sql - - sql = "SELECT django_datetime_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) - - # Should contain CASE statement - assert "CASE %s" in result - assert "django_datetime_trunc" not in result - - def test_year_truncation_in_case(self): - """Test year truncation template is in result.""" - from django_cf.db.base_engine import replace_date_trunc_in_sql - - sql = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) - - assert "WHEN 'year'" in result - assert "%Y-01-01" in result - - def test_month_truncation_in_case(self): - """Test month truncation template is in result.""" - from django_cf.db.base_engine import replace_date_trunc_in_sql - - sql = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) - - assert "WHEN 'month'" in result - assert "%Y-%m-01" in result - - def test_day_truncation_in_case(self): - """Test day truncation template is in result.""" - from django_cf.db.base_engine import replace_date_trunc_in_sql - - sql = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - result = replace_date_trunc_in_sql(sql) - - assert "WHEN 'day'" in result - assert "DATE(created_at)" in result - - def test_multiple_date_truncs(self): - """Test multiple django_date_trunc calls are replaced.""" - from django_cf.db.base_engine import replace_date_trunc_in_sql - - sql = """SELECT django_date_trunc(%s, created_at, %s, %s), - django_date_trunc(%s, updated_at, %s, %s) FROM orders""" - result = replace_date_trunc_in_sql(sql) - - assert "django_date_trunc" not in result - # Should have multiple CASE statements - assert result.count("CASE %s") == 2 - - -class TestCFDatabase: - """Tests for the CFDatabase class.""" - - def test_connect(self): - """Test CFDatabase.connect creates new instance.""" - from django_cf.db.base_engine import CFDatabase - - mock_wrapper = MagicMock() - db = CFDatabase.connect(mock_wrapper) - - assert isinstance(db, CFDatabase) - assert db.databaseWrapper == mock_wrapper - - def test_cursor_returns_self(self): - """Test cursor() returns self.""" - from django_cf.db.base_engine import CFDatabase - - db = CFDatabase(MagicMock()) - cursor = db.cursor() - - assert cursor is db - - def test_commit_does_nothing(self): - """Test commit() returns None (no-op).""" - from django_cf.db.base_engine import CFDatabase - - db = CFDatabase(MagicMock()) - result = db.commit() - - assert result is None - - def test_rollback_does_nothing(self): - """Test rollback() returns None (no-op).""" - from django_cf.db.base_engine import CFDatabase - - db = CFDatabase(MagicMock()) - result = db.rollback() - - assert result is None - - def test_close_does_nothing(self): - """Test close() returns None.""" - from django_cf.db.base_engine import CFDatabase - - db = CFDatabase(MagicMock()) - result = db.close() - - assert result is None - - def test_defer_foreign_keys(self): - """Test defer_foreign_keys correctly sets the instance variable.""" - from django_cf.db.base_engine import CFDatabase - - db = CFDatabase(MagicMock()) - - db.defer_foreign_keys(True) - assert db._defer_foreign_keys is True - - db.defer_foreign_keys(False) - assert db._defer_foreign_keys is False - - def test_execute_converts_boolean_true(self): - """Test execute converts True to 1.""" - from django_cf.db.base_engine import CFDatabase, CFResult - - mock_wrapper = MagicMock() - mock_wrapper.run_query.return_value = CFResult([]) - - db = CFDatabase(mock_wrapper) - db.execute("INSERT INTO test VALUES (%s)", (True,)) - - # Check that True was converted to 1 - call_args = mock_wrapper.run_query.call_args - assert call_args[0][1] == (1,) - - def test_execute_converts_boolean_false(self): - """Test execute converts False to 0.""" - from django_cf.db.base_engine import CFDatabase, CFResult - - mock_wrapper = MagicMock() - mock_wrapper.run_query.return_value = CFResult([]) - - db = CFDatabase(mock_wrapper) - db.execute("INSERT INTO test VALUES (%s)", (False,)) - - # Check that False was converted to 0 - call_args = mock_wrapper.run_query.call_args - assert call_args[0][1] == (0,) - - def test_execute_converts_decimal_to_string(self): - """Test execute converts Decimal to string.""" - from django_cf.db.base_engine import CFDatabase, CFResult - - mock_wrapper = MagicMock() - mock_wrapper.run_query.return_value = CFResult([]) - - db = CFDatabase(mock_wrapper) - db.execute("INSERT INTO test VALUES (%s)", (Decimal("10.5"),)) - - # Check that Decimal was converted to string - call_args = mock_wrapper.run_query.call_args - assert call_args[0][1] == ("10.5",) - - def test_execute_no_params(self): - """Test execute with no parameters.""" - from django_cf.db.base_engine import CFDatabase, CFResult - - mock_wrapper = MagicMock() - mock_wrapper.run_query.return_value = CFResult([]) - - db = CFDatabase(mock_wrapper) - db.execute("SELECT * FROM test") - - call_args = mock_wrapper.run_query.call_args - assert call_args[0][0] == "SELECT * FROM test" - assert call_args[0][1] is None - - def test_fetchone_delegates_to_result(self): - """Test fetchone delegates to lastResult.""" - from django_cf.db.base_engine import CFDatabase, CFResult - - mock_wrapper = MagicMock() - mock_result = CFResult([(1, "test")]) - mock_wrapper.run_query.return_value = mock_result - - db = CFDatabase(mock_wrapper) - db.execute("SELECT * FROM test") - row = db.fetchone() - - assert row == (1, "test") - - def test_fetchall_delegates_to_result(self): - """Test fetchall delegates to lastResult.""" - from django_cf.db.base_engine import CFDatabase, CFResult - - mock_wrapper = MagicMock() - mock_result = CFResult([(1, "a"), (2, "b")]) - mock_wrapper.run_query.return_value = mock_result - - db = CFDatabase(mock_wrapper) - db.execute("SELECT * FROM test") - rows = db.fetchall() - - assert len(rows) == 2 - - def test_lastrowid_property(self): - """Test lastrowid property delegates to lastResult.""" - from django_cf.db.base_engine import CFDatabase, CFResult - - mock_wrapper = MagicMock() - mock_result = CFResult([]) - mock_result.set_lastrowid(42) - mock_wrapper.run_query.return_value = mock_result - - db = CFDatabase(mock_wrapper) - db.execute("INSERT INTO test VALUES (1)") - - assert db.lastrowid == 42 - - def test_rowcount_property(self): - """Test rowcount property delegates to lastResult.""" - from django_cf.db.base_engine import CFDatabase, CFResult - - mock_wrapper = MagicMock() - mock_result = CFResult([]) - mock_result.set_rowcount(5) - mock_wrapper.run_query.return_value = mock_result - - db = CFDatabase(mock_wrapper) - db.execute('UPDATE test SET name = "new"') - - assert db.rowcount == 5 - - -class TestCFDatabaseFeatures: - """Tests for the CFDatabaseFeatures class.""" - - def test_transactions_disabled(self): - """Test that transactions are disabled.""" - from django_cf.db.base_engine import CFDatabaseFeatures - - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) - - assert features.atomic_transactions is False - assert features.supports_transactions is False - - def test_savepoints_disabled(self): - """Test that savepoints are disabled.""" - from django_cf.db.base_engine import CFDatabaseFeatures - - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) - - assert features.can_release_savepoints is False - - def test_constraint_checks_disabled(self): - """Test that constraint checks cannot be deferred.""" - from django_cf.db.base_engine import CFDatabaseFeatures - - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) - - assert features.can_defer_constraint_checks is False - assert features.supports_pragma_foreign_key_check is False - - def test_max_query_params(self): - """Test max_query_params is set.""" - from django_cf.db.base_engine import CFDatabaseFeatures - - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) - - assert features.max_query_params == 100 - - def test_bulk_insert_enabled(self): - """Test bulk insert is enabled.""" - from django_cf.db.base_engine import CFDatabaseFeatures - - mock_wrapper = MagicMock() - features = CFDatabaseFeatures(mock_wrapper) - - assert features.has_bulk_insert is True - assert features.can_return_columns_from_insert is True - - -class TestCFDatabaseWrapper: - """Tests for the CFDatabaseWrapper class.""" - - def test_get_database_version(self): - """Test get_database_version returns tuple.""" - from django_cf.db.base_engine import CFDatabaseWrapper - - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): - wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - version = wrapper.get_database_version() - - assert version == (4,) - - def test_close_does_nothing(self): - """Test close() is a no-op.""" - from django_cf.db.base_engine import CFDatabaseWrapper - - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): - wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - result = wrapper.close() - - assert result is None - - def test_savepoint_not_allowed(self): - """Test _savepoint_allowed returns False.""" - from django_cf.db.base_engine import CFDatabaseWrapper - - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): - wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - result = wrapper._savepoint_allowed() - - assert result is False - - def test_is_usable_always_true(self): - """Test is_usable always returns True.""" - from django_cf.db.base_engine import CFDatabaseWrapper - - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): - wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - result = wrapper.is_usable() - - assert result is True - - def test_run_query_not_implemented(self): - """Test run_query raises NotImplementedError.""" - from django_cf.db.base_engine import CFDatabaseWrapper - - with patch.object(CFDatabaseWrapper, "__init__", lambda x, *args: None): - wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) - - with pytest.raises(NotImplementedError): - wrapper.run_query("SELECT 1") - - -class TestCFDatabaseOperations: - """Tests for the CFDatabaseOperations class.""" - - def test_bulk_insert_sql(self): - """Test bulk_insert_sql generates correct SQL.""" - from django_cf.db.base_engine import CFDatabaseOperations - - mock_wrapper = MagicMock() - ops = CFDatabaseOperations(mock_wrapper) - - fields = ["id", "name"] - placeholder_rows = [("%s", "%s"), ("%s", "%s")] - - result = ops.bulk_insert_sql(fields, placeholder_rows) - - assert result == "VALUES (%s, %s), (%s, %s)" - - def test_last_executed_query_no_params(self): - """Test last_executed_query with no parameters.""" - from django_cf.db.base_engine import CFDatabaseOperations - - mock_wrapper = MagicMock() - ops = CFDatabaseOperations(mock_wrapper) - - sql = "SELECT * FROM test" - result = ops.last_executed_query(None, sql, None) - - assert result == sql - - def test_last_executed_query_with_params(self): - """Test last_executed_query with parameters.""" - from django_cf.db.base_engine import CFDatabaseOperations - - mock_wrapper = MagicMock() - mock_wrapper.connection = MagicMock() - mock_cursor = MagicMock() - mock_wrapper.connection.cursor.return_value = mock_cursor - ops = CFDatabaseOperations(mock_wrapper) - - sql = "SELECT * FROM test WHERE id = %s" - # The actual result depends on _quote_params_for_last_executed_query - # which may return None, causing substitution to either work or fall back - result = ops.last_executed_query(None, sql, [1]) - - # Result should be a string (either substituted or original) - assert isinstance(result, str) - assert "SELECT * FROM test WHERE id" in result - - def test_last_executed_query_catches_formatting_errors(self): - """Test last_executed_query catches Exception on format failure.""" - from django_cf.db.base_engine import CFDatabaseOperations - - mock_wrapper = MagicMock() - ops = CFDatabaseOperations(mock_wrapper) - - # Mismatched format string and params to trigger formatting error - sql = "SELECT * FROM test WHERE id = %s AND name = %s" - params = (1,) # Too few params for the format string - - result = ops.last_executed_query(None, sql, params) - - # Should fall back to returning original sql on formatting error - assert result == sql - - def test_last_executed_query_does_not_catch_keyboard_interrupt(self): - """Test last_executed_query does not suppress KeyboardInterrupt.""" - from django_cf.db.base_engine import CFDatabaseOperations - - mock_wrapper = MagicMock() - ops = CFDatabaseOperations(mock_wrapper) - - # Object whose __str__ raises KeyboardInterrupt during sql % params - class BadStr: - def __str__(self): - raise KeyboardInterrupt() - - def __format__(self, spec): - raise KeyboardInterrupt() - - # Patch _quote_params to return a tuple with our bad object - ops._quote_params_for_last_executed_query = lambda params: (BadStr(),) - - sql = "SELECT * FROM test WHERE id = %s" - - with pytest.raises(KeyboardInterrupt): - ops.last_executed_query(None, sql, [1]) - - -class TestCFSQLCompiler: - """Tests for the CFSQLCompiler class.""" - - def test_replace_date_trunc_functions_year(self): - """Test _replace_date_trunc_functions for year truncation.""" - from django_cf.db.base_engine import CFSQLCompiler - - compiler = CFSQLCompiler.__new__(CFSQLCompiler) - - sql = "SELECT django_date_trunc('year', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) - - assert 'STRFTIME("%Y-01-01", created_at)' in result - assert "django_date_trunc" not in result - - def test_replace_date_trunc_functions_month(self): - """Test _replace_date_trunc_functions for month truncation.""" - from django_cf.db.base_engine import CFSQLCompiler - - compiler = CFSQLCompiler.__new__(CFSQLCompiler) - - sql = "SELECT django_date_trunc('month', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) - - assert 'STRFTIME("%Y-%m-01", created_at)' in result - - def test_replace_date_trunc_functions_day(self): - """Test _replace_date_trunc_functions for day truncation.""" - from django_cf.db.base_engine import CFSQLCompiler - - compiler = CFSQLCompiler.__new__(CFSQLCompiler) - - sql = "SELECT django_date_trunc('day', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) - - assert "DATE(created_at)" in result - - def test_replace_date_trunc_functions_hour(self): - """Test _replace_date_trunc_functions for hour truncation.""" - from django_cf.db.base_engine import CFSQLCompiler - - compiler = CFSQLCompiler.__new__(CFSQLCompiler) - - sql = "SELECT django_date_trunc('hour', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) - - assert 'STRFTIME("%Y-%m-%d %H:00:00", created_at)' in result - - def test_replace_date_trunc_functions_unknown_kind(self): - """Test _replace_date_trunc_functions with unknown kind returns original.""" - from django_cf.db.base_engine import CFSQLCompiler - - compiler = CFSQLCompiler.__new__(CFSQLCompiler) - - sql = "SELECT django_date_trunc('unknown', created_at) FROM orders" - result = compiler._replace_date_trunc_functions(sql) - - # Unknown kind should leave the original match - assert "django_date_trunc('unknown', created_at)" in result diff --git a/packages/django-cf/tests/db/test_d1_backend.py b/packages/django-cf/tests/db/test_d1_backend.py deleted file mode 100644 index 5c109f76..00000000 --- a/packages/django-cf/tests/db/test_d1_backend.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Tests for django_cf/db/backends/d1/base.py - D1 database backend.""" - -import sys -from unittest.mock import MagicMock, patch - -import pytest - - -class TestD1DatabaseWrapperProcessQuery: - """Tests for D1 DatabaseWrapper.process_query method.""" - - def _create_mock_wrapper(self): - """Create a mock D1 DatabaseWrapper for testing.""" - # We need to mock the worker imports before importing the module - mock_workers = MagicMock() - mock_run_sync = MagicMock() - - with patch.dict( - sys.modules, - {"workers": mock_workers, "pyodide.ffi": MagicMock(run_sync=mock_run_sync)}, - ): - # Create a mock wrapper that mimics D1 DatabaseWrapper - class MockD1Wrapper: - def __init__(self): - self.run_sync = mock_run_sync - self._cursor_mock = MagicMock() - self._cursor_mock._defer_foreign_keys = False - - def cursor(self): - return self._cursor_mock - - def process_query(self, query, params=None): - # Import the actual function we want to test - from django_cf.db.base_engine import replace_date_trunc_in_sql - - # Replace date trunc - query = replace_date_trunc_in_sql(query) - - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - - if self.cursor()._defer_foreign_keys: - return f""" - PRAGMA defer_foreign_keys = on - - {query} - - PRAGMA defer_foreign_keys = off - """ - - return query, params - - return MockD1Wrapper() - - def test_process_query_no_params(self): - """Test process_query replaces %s with ? when no params.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT * FROM users WHERE id = %s" - result_query, result_params = wrapper.process_query(query, None) - - assert result_query == "SELECT * FROM users WHERE id = ?" - assert result_params is None - - def test_process_query_with_params(self): - """Test process_query replaces %s with ? for each param.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT * FROM users WHERE id = %s AND name = %s" - params = [1, "test"] - result_query, result_params = wrapper.process_query(query, params) - - assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" - assert result_params == [1, "test"] - - def test_process_query_null_param_replaced_with_literal(self): - """Test process_query replaces None params with literal 'null'.""" - wrapper = self._create_mock_wrapper() - - query = "INSERT INTO users (name, email) VALUES (%s, %s)" - params = ["test", None] - result_query, result_params = wrapper.process_query(query, params) - - # None should be replaced with literal 'null', not ? - assert "null" in result_query - assert result_params == ["test"] - - def test_process_query_all_null_params(self): - """Test process_query when all params are None.""" - wrapper = self._create_mock_wrapper() - - query = "INSERT INTO users (name, email) VALUES (%s, %s)" - params = [None, None] - result_query, result_params = wrapper.process_query(query, params) - - assert result_query == "INSERT INTO users (name, email) VALUES (null, null)" - assert result_params == [] - - def test_process_query_mixed_params(self): - """Test process_query with mixed None and non-None params.""" - wrapper = self._create_mock_wrapper() - - query = "UPDATE users SET name = %s, email = %s, age = %s WHERE id = %s" - params = ["test", None, 25, None] - result_query, result_params = wrapper.process_query(query, params) - - assert result_params == ["test", 25] - # Count ? marks - should be 2 (for 'test' and 25) - assert result_query.count("?") == 2 - # Count null literals - should be 2 - assert result_query.count("null") == 2 - - def test_process_query_with_defer_foreign_keys(self): - """Test process_query adds PRAGMA when _defer_foreign_keys is True.""" - wrapper = self._create_mock_wrapper() - wrapper._cursor_mock._defer_foreign_keys = True - - query = "INSERT INTO users (name) VALUES (%s)" - params = ["test"] - result = wrapper.process_query(query, params) - - # When defer_foreign_keys is True, returns string (not tuple) - # This is actually a bug in the implementation - assert "PRAGMA defer_foreign_keys = on" in result - assert "PRAGMA defer_foreign_keys = off" in result - - def test_process_query_date_trunc_replacement(self): - """Test process_query replaces django_date_trunc calls.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - params = ["year", "UTC", "UTC"] - result_query, result_params = wrapper.process_query(query, params) - - # Date trunc should be replaced - assert "django_date_trunc" not in result_query - assert "CASE" in result_query or "STRFTIME" in result_query - - -class TestD1DatabaseWrapperConfiguration: - """Tests for D1 DatabaseWrapper configuration.""" - - def test_vendor_name(self): - """Test vendor name is correct.""" - # We can't fully import without worker environment, - # but we can check the class definition - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.d1.base") - assert spec is not None - - def test_display_name(self): - """Test display name is 'D1'.""" - # Read the source to verify the display_name - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.d1.base") - with open(spec.origin) as f: - content = f.read() - assert 'display_name = "D1"' in content - assert 'vendor = "cloudflare_d1"' in content - - -class TestD1GetConnectionParams: - """Tests for D1 get_connection_params method.""" - - def test_missing_binding_raises_error(self): - """Test that missing CLOUDFLARE_BINDING raises ImproperlyConfigured.""" - from django.core.exceptions import ImproperlyConfigured - - # Create minimal mock to test get_connection_params logic - class MockWrapper: - settings_dict = {"CLOUDFLARE_BINDING": None} - - def get_connection_params(self): - if not self.settings_dict["CLOUDFLARE_BINDING"]: - raise ImproperlyConfigured( - "settings.DATABASES is improperly configured. " - "Please supply the CLOUDFLARE_BINDING value." - ) - return {"binding": self.settings_dict["CLOUDFLARE_BINDING"]} - - wrapper = MockWrapper() - with pytest.raises(ImproperlyConfigured) as exc_info: - wrapper.get_connection_params() - - assert "CLOUDFLARE_BINDING" in str(exc_info.value) - - def test_valid_binding_returns_params(self): - """Test that valid CLOUDFLARE_BINDING returns correct params.""" - - class MockWrapper: - settings_dict = {"CLOUDFLARE_BINDING": "MY_DB"} - - def get_connection_params(self): - if not self.settings_dict["CLOUDFLARE_BINDING"]: - from django.core.exceptions import ImproperlyConfigured - - raise ImproperlyConfigured( - "settings.DATABASES is improperly configured. " - "Please supply the CLOUDFLARE_BINDING value." - ) - return {"binding": self.settings_dict["CLOUDFLARE_BINDING"]} - - wrapper = MockWrapper() - params = wrapper.get_connection_params() - - assert params == {"binding": "MY_DB"} - - -class TestD1ExceptionHandling: - """Tests for D1 backend exception handling.""" - - def test_run_query_uses_except_exception(self): - """Test that run_query uses 'except Exception' instead of bare 'except'. - - Bare except clauses catch BaseException subclasses like KeyboardInterrupt - and SystemExit, which should be allowed to propagate normally. - """ - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.d1.base") - with open(spec.origin) as f: - content = f.read() - # Should use 'except Exception:' not bare 'except:' - assert "except Exception:" in content - # Should NOT have bare except (except inside 'except Exception') - lines = content.split("\n") - for line in lines: - stripped = line.strip() - if stripped.startswith("except") and stripped.endswith(":"): - assert stripped != "except:", f"Found bare 'except:' clause: {line}" - - -class TestD1ParameterHandling: - """Tests for D1 parameter type handling edge cases.""" - - def test_empty_params_list(self): - """Test handling of empty params list.""" - - # Create minimal process_query implementation - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "SELECT * FROM users" - result_query, result_params = process_query(query, []) - - assert result_query == "SELECT * FROM users" - assert result_params == [] - - def test_special_characters_in_params(self): - """Test handling of special characters in parameters.""" - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "INSERT INTO users (name) VALUES (%s)" - params = ["test'; DROP TABLE users; --"] - result_query, result_params = process_query(query, params) - - # The dangerous string should be kept as a parameter, not interpolated - assert result_params == ["test'; DROP TABLE users; --"] - assert "?" in result_query - - def test_unicode_params(self): - """Test handling of unicode parameters.""" - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "INSERT INTO users (name) VALUES (%s)" - params = [""] - result_query, result_params = process_query(query, params) - - assert result_params == [""] - - def test_large_number_of_params(self): - """Test handling of many parameters.""" - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - placeholders = ", ".join(["%s"] * 50) - query = f"INSERT INTO test VALUES ({placeholders})" - params = list(range(50)) - result_query, result_params = process_query(query, params) - - assert result_query.count("?") == 50 - assert len(result_params) == 50 diff --git a/packages/django-cf/tests/db/test_do_backend.py b/packages/django-cf/tests/db/test_do_backend.py deleted file mode 100644 index 4b943842..00000000 --- a/packages/django-cf/tests/db/test_do_backend.py +++ /dev/null @@ -1,382 +0,0 @@ -"""Tests for django_cf/db/backends/do/base.py - Durable Objects database backend.""" - -from unittest.mock import MagicMock - - -class TestDODatabaseWrapperProcessQuery: - """Tests for DO DatabaseWrapper.process_query method.""" - - def _create_mock_wrapper(self): - """Create a mock DO DatabaseWrapper for testing.""" - - class MockDOWrapper: - def __init__(self): - self._cursor_mock = MagicMock() - self._cursor_mock._defer_foreign_keys = False - - def cursor(self): - return self._cursor_mock - - def process_query(self, query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - - if self.cursor()._defer_foreign_keys: - return f""" - PRAGMA defer_foreign_keys = on - - {query} - - PRAGMA defer_foreign_keys = off - """ - - return query, params - - return MockDOWrapper() - - def test_process_query_no_params(self): - """Test process_query replaces %s with ? when no params.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT * FROM users WHERE id = %s" - result_query, result_params = wrapper.process_query(query, None) - - assert result_query == "SELECT * FROM users WHERE id = ?" - assert result_params is None - - def test_process_query_with_params(self): - """Test process_query replaces %s with ? for each param.""" - wrapper = self._create_mock_wrapper() - - query = "SELECT * FROM users WHERE id = %s AND name = %s" - params = [1, "test"] - result_query, result_params = wrapper.process_query(query, params) - - assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" - assert result_params == [1, "test"] - - def test_process_query_null_param_replaced_with_literal(self): - """Test process_query replaces None params with literal 'null'.""" - wrapper = self._create_mock_wrapper() - - query = "INSERT INTO users (name, email) VALUES (%s, %s)" - params = ["test", None] - result_query, result_params = wrapper.process_query(query, params) - - assert "null" in result_query - assert result_params == ["test"] - - def test_process_query_with_defer_foreign_keys(self): - """Test process_query adds PRAGMA when _defer_foreign_keys is True.""" - wrapper = self._create_mock_wrapper() - wrapper._cursor_mock._defer_foreign_keys = True - - query = "INSERT INTO users (name) VALUES (%s)" - params = ["test"] - result = wrapper.process_query(query, params) - - assert "PRAGMA defer_foreign_keys = on" in result - assert "PRAGMA defer_foreign_keys = off" in result - - -class TestDODatabaseWrapperConfiguration: - """Tests for DO DatabaseWrapper configuration.""" - - def test_vendor_name(self): - """Test vendor name is correct.""" - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.base") - with open(spec.origin) as f: - content = f.read() - assert 'vendor = "cloudflare_durable_objects"' in content - assert 'display_name = "DO"' in content - - def test_get_connection_params_returns_empty(self): - """Test that get_connection_params returns empty dict for DO.""" - - # DO backend doesn't need connection params like D1 does - class MockDOWrapper: - def get_connection_params(self): - return {} - - wrapper = MockDOWrapper() - params = wrapper.get_connection_params() - - assert params == {} - - -class TestDOMissingDateTruncBug: - """Tests documenting the missing date_trunc bug in DO backend.""" - - def test_do_process_query_missing_date_trunc(self): - """ - Test demonstrating that DO backend is missing date_trunc replacement. - - The D1 backend calls replace_date_trunc_in_sql() in process_query(), - but the DO backend does NOT. This is a bug that should be fixed. - """ - - # DO backend's process_query (simulated - without date_trunc) - def do_process_query(query, params=None): - # Note: NO replace_date_trunc_in_sql call - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - # D1 backend's process_query (simulated - with date_trunc) - def d1_process_query(query, params=None): - from django_cf.db.base_engine import replace_date_trunc_in_sql - - query = replace_date_trunc_in_sql(query) - - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - test_query = "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" - test_params = ["year", "UTC", "UTC"] - - # D1 correctly replaces date_trunc - d1_result, _ = d1_process_query(test_query, test_params) - assert "django_date_trunc" not in d1_result - - # DO does NOT replace date_trunc (BUG) - do_result, _ = do_process_query(test_query, test_params) - # This shows the bug - django_date_trunc is still in the query - # When this test fails after fix, remove the bug documentation - assert "django_date_trunc" in do_result # Bug: should NOT be in result - - -class TestDOStorageInitialization: - """Tests for DO storage initialization.""" - - def test_storage_module_exists(self): - """Test that storage module exists for DO backend.""" - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.storage") - assert spec is not None - - def test_get_storage_function_exists(self): - """Test that get_storage function exists in storage module.""" - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.base") - with open(spec.origin) as f: - content = f.read() - assert "from .storage import get_storage" in content - - -class TestDOQueryExecution: - """Tests for DO query execution patterns.""" - - def test_read_query_uses_raw(self): - """Test that read queries use raw() method.""" - - # Simulated DO run_query logic - def mock_run_query(query, params=None, is_read=True): - mock_db = MagicMock() - mock_stmt = MagicMock() - - if params: - mock_db.exec.return_value = mock_stmt - else: - mock_db.exec.return_value = mock_stmt - - if is_read: - # Read queries call raw().toArray() - mock_stmt.raw.return_value.toArray.return_value.to_py.return_value = [] - return mock_stmt.raw().toArray().to_py() - else: - return mock_stmt - - result = mock_run_query("SELECT * FROM users", is_read=True) - assert result == [] - - def test_error_handling_exposes_stack(self): - """ - Test documenting that DO backend exposes full stack traces. - - This is a potential security issue - stack traces should not - be exposed in production errors. - """ - # The DO backend has this pattern: - # except: - # from js import Error - # Error.stackTraceLimit = 1e10 - # raise Error(Error.new().stack) - - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.base") - with open(spec.origin) as f: - content = f.read() - # Verify the problematic pattern exists - assert "Error.stackTraceLimit = 1e10" in content - assert "raise Error(Error.new().stack)" in content - - -class TestDOExceptionHandling: - """Tests for DO backend exception handling.""" - - def test_run_query_uses_except_exception(self): - """Test that run_query uses 'except Exception' instead of bare 'except'. - - Bare except clauses catch BaseException subclasses like KeyboardInterrupt - and SystemExit, which should be allowed to propagate normally. - """ - import importlib.util - - spec = importlib.util.find_spec("django_cf.db.backends.do.base") - with open(spec.origin) as f: - content = f.read() - # Should use 'except Exception:' not bare 'except:' - assert "except Exception:" in content - # Should NOT have bare except - lines = content.split("\n") - for line in lines: - stripped = line.strip() - if stripped.startswith("except") and stripped.endswith(":"): - assert stripped != "except:", f"Found bare 'except:' clause: {line}" - - -class TestDOParamConversion: - """Tests for DO parameter type conversion.""" - - def test_boolean_true_not_converted_in_process_query(self): - """ - Test that boolean True is NOT converted in process_query. - - Note: Boolean conversion happens in CFDatabase.execute(), not process_query(). - """ - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "INSERT INTO test (active) VALUES (%s)" - params = [True] - result_query, result_params = process_query(query, params) - - # process_query doesn't convert booleans - that's CFDatabase.execute's job - assert result_params == [True] - - def test_multiple_none_params_order_preserved(self): - """Test that order is preserved with multiple None params.""" - - def process_query(query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - return query, params - - query = "INSERT INTO test (a, b, c, d) VALUES (%s, %s, %s, %s)" - params = [1, None, 2, None] - result_query, result_params = process_query(query, params) - - # Non-None params should be in order - assert result_params == [1, 2] - # Query should have correct mix of ? and null - assert result_query == "INSERT INTO test (a, b, c, d) VALUES (?, null, ?, null)" - - -class TestDOPragmaReturnTypeBug: - """Tests documenting the PRAGMA return type bug in DO backend.""" - - def test_pragma_returns_string_not_tuple(self): - """ - Test demonstrating that PRAGMA mode returns string instead of tuple. - - When _defer_foreign_keys is True, process_query returns a string - instead of a (query, params) tuple. This causes the params to be lost. - """ - - class MockDOWrapper: - def __init__(self): - self._cursor_mock = MagicMock() - self._cursor_mock._defer_foreign_keys = True - - def cursor(self): - return self._cursor_mock - - def process_query(self, query, params=None): - if params is None: - query = query.replace("%s", "?") - else: - new_params = [] - for param in params: - if param is None: - query = query.replace("%s", "null", 1) - else: - new_params.append(param) - query = query.replace("%s", "?", 1) - params = new_params - - if self.cursor()._defer_foreign_keys: - # BUG: Returns string, not tuple - params are lost! - return f""" - PRAGMA defer_foreign_keys = on - - {query} - - PRAGMA defer_foreign_keys = off - """ - - return query, params - - wrapper = MockDOWrapper() - query = "INSERT INTO users (name) VALUES (%s)" - params = ["test"] - - result = wrapper.process_query(query, params) - - # Bug: result is a string, not a tuple - assert isinstance(result, str) - # The params ['test'] are completely lost! - # This will cause issues when trying to unpack: proc_query, params = result diff --git a/packages/django-cf/tests/in_worker/conftest.py b/packages/django-cf/tests/in_worker/conftest.py new file mode 100644 index 00000000..39f35bfd --- /dev/null +++ b/packages/django-cf/tests/in_worker/conftest.py @@ -0,0 +1,13 @@ +from tests.in_worker_harness import ( + CompatConfig, + compat_config, + dev_server, + worker_project_dir, +) + +__all__ = [ + "CompatConfig", + "compat_config", + "dev_server", + "worker_project_dir", +] diff --git a/packages/django-cf/tests/in_worker/test_in_worker.py b/packages/django-cf/tests/in_worker/test_in_worker.py new file mode 100644 index 00000000..a9661ea6 --- /dev/null +++ b/packages/django-cf/tests/in_worker/test_in_worker.py @@ -0,0 +1,49 @@ +# pyright: reportMissingImports=false, reportMissingModuleSource=false + +from pathlib import Path + +import pytest +import requests + +from tests.in_worker_harness import register_in_worker_suites + +IN_WORKER_DIR: Path = Path(__file__).parent / "worker" +IN_WORKER_SRC_DIR: Path = IN_WORKER_DIR / "src" + + +@pytest.fixture(scope="module") +def worker_project_dir() -> Path: + return IN_WORKER_DIR + + +register_in_worker_suites(globals(), IN_WORKER_SRC_DIR) + + +def test_wsgi_header_transformation(dev_server: str) -> None: + response = requests.get( + f"{dev_server}/wsgi/headers", + headers={ + "cf-access-jwt-assertion": "jwt-token", + "x-custom-header": "custom-value", + "content-type": "text/plain", + }, + timeout=10, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["cf_access"] == "jwt-token" + assert payload["custom"] == "custom-value" + assert payload["content_type"] == "text/plain" + + +def test_wsgi_reads_request_body(dev_server: str) -> None: + response = requests.post( + f"{dev_server}/wsgi/body", + headers={"content-type": "text/plain"}, + data=b"request-body", + timeout=10, + ) + + assert response.status_code == 200 + assert response.text == "request-body" diff --git a/packages/django-cf/tests/in_worker/worker/pyproject.toml b/packages/django-cf/tests/in_worker/worker/pyproject.toml new file mode 100644 index 00000000..21f53690 --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "django-cf-host-only-tests" +version = "0.0.0" +requires-python = ">=3.12" +dependencies = [ + "django<6.1", + "pytest", + "sqlparse", +] diff --git a/packages/django-cf/tests/in_worker/worker/src/conftest.py b/packages/django-cf/tests/in_worker/worker/src/conftest.py new file mode 100644 index 00000000..8f18c4eb --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/conftest.py @@ -0,0 +1,15 @@ +# pyright: reportMissingImports=false + +import pytest +from django.core.cache import cache +from workers import env as _env + + +@pytest.fixture(autouse=True) +def clear_cache(): + cache.clear() + + +@pytest.fixture +def env(): + return _env diff --git a/packages/django-cf/tests/in_worker/worker/src/test_db.py b/packages/django-cf/tests/in_worker/worker/src/test_db.py new file mode 100644 index 00000000..8bb17d13 --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_db.py @@ -0,0 +1,1027 @@ +"""Database backend tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +import workers + + +def make_d1_wrapper(defer_foreign_keys=False): + from django_cf.db.backends.d1.base import DatabaseWrapper + + wrapper = DatabaseWrapper.__new__(DatabaseWrapper) + cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) + wrapper.cursor = lambda: cursor_state + wrapper.binding = "DB" + wrapper.run_sync = lambda value: value + return wrapper + + +def make_do_wrapper(defer_foreign_keys=False): + from django_cf.db.backends.do.base import DatabaseWrapper + + wrapper = DatabaseWrapper.__new__(DatabaseWrapper) + cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) + wrapper.cursor = lambda: cursor_state + return wrapper + + +class FakeD1Statement: + def __init__( + self, *, raw_result=None, all_result=None, raw_error=None, all_error=None + ): + self.raw_result = raw_result or [] + self.all_result = all_result or {"results": [], "meta": {}} + self.raw_error = raw_error + self.all_error = all_error + self.bound_params = None + + def bind(self, *params): + self.bound_params = params + return self + + def raw(self): + if self.raw_error is not None: + raise self.raw_error + return self.raw_result + + def all(self): + if self.all_error is not None: + raise self.all_error + return self.all_result + + +class FakeD1Binding: + def __init__(self, statement): + self.statement = statement + self.prepared_queries = [] + + def prepare(self, query): + self.prepared_queries.append(query) + return self.statement + + +class FakeDOArray: + def __init__(self, data): + self.data = data + + def toArray(self): + return self + + def to_py(self): + return self.data + + +class FakeDOStatement: + def __init__(self, data, *, rows_read=0, rows_written=0): + self.data = data + self.rowsRead = rows_read + self.rowsWritten = rows_written + + def raw(self): + return FakeDOArray(self.data) + + +class FakeDOStorage: + def __init__(self, statement=None, *, error=None): + self.statement = statement + self.error = error + self.calls = [] + + def exec(self, query, *params): + if self.error is not None: + raise self.error + self.calls.append((query, params)) + return self.statement + + +class TestCFResult: + def test_init(self): + from django_cf.db.base_engine import CFResult + + data = [(1, "a"), (2, "b"), (3, "c")] + result = CFResult(data) + + assert result.data == data + assert result.lastrowid is None + assert result.rowcount == -1 + + def test_iter(self): + from django_cf.db.base_engine import CFResult + + data = [(1, "a"), (2, "b")] + result = CFResult(data) + + assert list(result) == [(1, "a"), (2, "b")] + + def test_set_lastrowid(self): + from django_cf.db.base_engine import CFResult + + result = CFResult([]) + result.set_lastrowid(42) + + assert result.lastrowid == 42 + + def test_set_rowcount(self): + from django_cf.db.base_engine import CFResult + + result = CFResult([]) + result.set_rowcount(10) + + assert result.rowcount == 10 + + def test_fetchone_with_data(self): + from django_cf.db.base_engine import CFResult + + data = [(1, "a"), (2, "b"), (3, "c")] + result = CFResult(data) + + row = result.fetchone() + assert row == (3, "c") + assert len(result.data) == 2 + + def test_fetchone_empty(self): + from django_cf.db.base_engine import CFResult + + result = CFResult([]) + + assert result.fetchone() is None + + def test_fetchall(self): + from django_cf.db.base_engine import CFResult + + data = [(1, "a"), (2, "b"), (3, "c")] + result = CFResult(data) + + assert result.fetchall() == [(3, "c"), (2, "b"), (1, "a")] + assert len(result.data) == 0 + + def test_fetchall_empty(self): + from django_cf.db.base_engine import CFResult + + assert CFResult([]).fetchall() == [] + + def test_fetchmany_default(self): + from django_cf.db.base_engine import CFResult + + data = [(1, "a"), (2, "b"), (3, "c")] + result = CFResult(data) + + assert result.fetchmany() == [(3, "c")] + assert len(result.data) == 2 + + def test_fetchmany_specific_size(self): + from django_cf.db.base_engine import CFResult + + data = [(1, "a"), (2, "b"), (3, "c")] + result = CFResult(data) + + rows = result.fetchmany(2) + assert len(rows) == 2 + assert len(result.data) == 1 + + def test_fetchmany_more_than_available(self): + from django_cf.db.base_engine import CFResult + + data = [(1, "a"), (2, "b")] + result = CFResult(data) + + rows = result.fetchmany(5) + assert len(rows) == 2 + assert len(result.data) == 0 + + def test_from_object_with_list_rows(self): + from django_cf.db.base_engine import CFResult + + data = [[1, "hello", True], [2, "world", False]] + result = CFResult.from_object("SELECT * FROM test", None, data) + + assert list(result) == [(1, "hello", True), (2, "world", False)] + + def test_from_object_with_dict_rows(self): + from django_cf.db.base_engine import CFResult + + data = [{"id": 1, "name": "hello"}, {"id": 2, "name": "world"}] + result = CFResult.from_object("SELECT * FROM test", None, data) + + assert len(list(result)) == 2 + + def test_from_object_insert_rowcount(self): + from django_cf.db.base_engine import CFResult + + result = CFResult.from_object( + "INSERT INTO test VALUES (1)", None, [], rows_read=0, rows_written=5 + ) + + assert result.rowcount == 5 + + def test_from_object_update_rowcount(self): + from django_cf.db.base_engine import CFResult + + result = CFResult.from_object( + 'UPDATE test SET name = "new"', None, [], rows_read=0, rows_written=3 + ) + + assert result.rowcount == 3 + + def test_from_object_delete_rowcount(self): + from django_cf.db.base_engine import CFResult + + result = CFResult.from_object( + "DELETE FROM test WHERE id = 1", None, [], rows_read=0, rows_written=2 + ) + + assert result.rowcount == 2 + + def test_from_object_select_rowcount(self): + from django_cf.db.base_engine import CFResult + + result = CFResult.from_object( + "SELECT * FROM test", None, [[1], [2], [3]], rows_read=3, rows_written=0 + ) + + assert result.rowcount == 3 + + def test_from_object_lastrowid(self): + from django_cf.db.base_engine import CFResult + + result = CFResult.from_object( + "INSERT INTO test VALUES (1)", None, [], last_row_id=42 + ) + + assert result.lastrowid == 42 + + +class TestIsReadOnlyQuery: + def test_select_query(self): + from django_cf.db.base_engine import is_read_only_query + + assert is_read_only_query("SELECT * FROM users") is True + assert is_read_only_query(" SELECT id FROM users WHERE id = 1") is True + assert is_read_only_query("select * from users") is True + + def test_insert_query(self): + from django_cf.db.base_engine import is_read_only_query + + assert is_read_only_query('INSERT INTO users (name) VALUES ("test")') is False + + def test_update_query(self): + from django_cf.db.base_engine import is_read_only_query + + assert is_read_only_query('UPDATE users SET name = "new" WHERE id = 1') is False + + def test_delete_query(self): + from django_cf.db.base_engine import is_read_only_query + + assert is_read_only_query("DELETE FROM users WHERE id = 1") is False + + def test_create_table_query(self): + from django_cf.db.base_engine import is_read_only_query + + assert is_read_only_query("CREATE TABLE users (id INT)") is False + + def test_alter_table_query(self): + from django_cf.db.base_engine import is_read_only_query + + assert is_read_only_query("ALTER TABLE users ADD COLUMN email TEXT") is False + + def test_drop_table_query(self): + from django_cf.db.base_engine import is_read_only_query + + assert is_read_only_query("DROP TABLE users") is False + + def test_replace_query(self): + from django_cf.db.base_engine import is_read_only_query + + assert ( + is_read_only_query('REPLACE INTO users (id, name) VALUES (1, "test")') + is False + ) + + def test_empty_query(self): + from django_cf.db.base_engine import is_read_only_query + + assert is_read_only_query("") is False + assert is_read_only_query(" ") is False + + +class TestReplaceDateTruncInSql: + def test_no_date_trunc(self): + from django_cf.db.base_engine import replace_date_trunc_in_sql + + sql = 'SELECT * FROM users WHERE created_at > "2023-01-01"' + assert replace_date_trunc_in_sql(sql) == sql + + def test_django_date_trunc_replacement(self): + from django_cf.db.base_engine import replace_date_trunc_in_sql + + result = replace_date_trunc_in_sql( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" + ) + + assert "CASE %s" in result + assert "STRFTIME" in result + assert "django_date_trunc" not in result + + def test_django_datetime_trunc_replacement(self): + from django_cf.db.base_engine import replace_date_trunc_in_sql + + result = replace_date_trunc_in_sql( + "SELECT django_datetime_trunc(%s, created_at, %s, %s) FROM orders" + ) + + assert "CASE %s" in result + assert "django_datetime_trunc" not in result + + def test_year_truncation_in_case(self): + from django_cf.db.base_engine import replace_date_trunc_in_sql + + result = replace_date_trunc_in_sql( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" + ) + + assert "WHEN 'year'" in result + assert "%Y-01-01" in result + + def test_month_truncation_in_case(self): + from django_cf.db.base_engine import replace_date_trunc_in_sql + + result = replace_date_trunc_in_sql( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" + ) + + assert "WHEN 'month'" in result + assert "%Y-%m-01" in result + + def test_day_truncation_in_case(self): + from django_cf.db.base_engine import replace_date_trunc_in_sql + + result = replace_date_trunc_in_sql( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders" + ) + + assert "WHEN 'day'" in result + assert "DATE(created_at)" in result + + def test_multiple_date_truncs(self): + from django_cf.db.base_engine import replace_date_trunc_in_sql + + sql = """SELECT django_date_trunc(%s, created_at, %s, %s), + django_date_trunc(%s, updated_at, %s, %s) FROM orders""" + result = replace_date_trunc_in_sql(sql) + + assert "django_date_trunc" not in result + assert result.count("CASE %s") == 2 + + +class TestCFDatabase: + def test_connect(self): + from django_cf.db.base_engine import CFDatabase + + mock_wrapper = MagicMock() + db = CFDatabase.connect(mock_wrapper) + + assert db.databaseWrapper == mock_wrapper + + def test_cursor_returns_self(self): + from django_cf.db.base_engine import CFDatabase + + db = CFDatabase(MagicMock()) + assert db.cursor() is db + + def test_commit_does_nothing(self): + from django_cf.db.base_engine import CFDatabase + + assert CFDatabase(MagicMock()).commit() is None + + def test_rollback_does_nothing(self): + from django_cf.db.base_engine import CFDatabase + + assert CFDatabase(MagicMock()).rollback() is None + + def test_close_does_nothing(self): + from django_cf.db.base_engine import CFDatabase + + assert CFDatabase(MagicMock()).close() is None + + def test_defer_foreign_keys(self): + from django_cf.db.base_engine import CFDatabase + + db = CFDatabase(MagicMock()) + db.defer_foreign_keys(True) + assert db._defer_foreign_keys is True + db.defer_foreign_keys(False) + assert db._defer_foreign_keys is False + + def test_execute_converts_boolean_true(self): + from django_cf.db.base_engine import CFDatabase, CFResult + + mock_wrapper = MagicMock() + mock_wrapper.run_query.return_value = CFResult([]) + db = CFDatabase(mock_wrapper) + + db.execute("INSERT INTO test VALUES (%s)", (True,)) + + assert mock_wrapper.run_query.call_args[0][1] == (1,) + + def test_execute_converts_boolean_false(self): + from django_cf.db.base_engine import CFDatabase, CFResult + + mock_wrapper = MagicMock() + mock_wrapper.run_query.return_value = CFResult([]) + db = CFDatabase(mock_wrapper) + + db.execute("INSERT INTO test VALUES (%s)", (False,)) + + assert mock_wrapper.run_query.call_args[0][1] == (0,) + + def test_execute_converts_decimal_to_string(self): + from django_cf.db.base_engine import CFDatabase, CFResult + + mock_wrapper = MagicMock() + mock_wrapper.run_query.return_value = CFResult([]) + db = CFDatabase(mock_wrapper) + + db.execute("INSERT INTO test VALUES (%s)", (Decimal("10.5"),)) + + assert mock_wrapper.run_query.call_args[0][1] == ("10.5",) + + def test_execute_no_params(self): + from django_cf.db.base_engine import CFDatabase, CFResult + + mock_wrapper = MagicMock() + mock_wrapper.run_query.return_value = CFResult([]) + db = CFDatabase(mock_wrapper) + + db.execute("SELECT * FROM test") + + assert mock_wrapper.run_query.call_args[0] == ("SELECT * FROM test", None) + + def test_fetchone_delegates_to_result(self): + from django_cf.db.base_engine import CFDatabase, CFResult + + mock_wrapper = MagicMock() + mock_wrapper.run_query.return_value = CFResult([(1, "test")]) + db = CFDatabase(mock_wrapper) + + db.execute("SELECT * FROM test") + + assert db.fetchone() == (1, "test") + + def test_fetchall_delegates_to_result(self): + from django_cf.db.base_engine import CFDatabase, CFResult + + mock_wrapper = MagicMock() + mock_wrapper.run_query.return_value = CFResult([(1, "a"), (2, "b")]) + db = CFDatabase(mock_wrapper) + + db.execute("SELECT * FROM test") + + assert len(db.fetchall()) == 2 + + def test_lastrowid_property(self): + from django_cf.db.base_engine import CFDatabase, CFResult + + mock_wrapper = MagicMock() + mock_result = CFResult([]) + mock_result.set_lastrowid(42) + mock_wrapper.run_query.return_value = mock_result + db = CFDatabase(mock_wrapper) + + db.execute("INSERT INTO test VALUES (1)") + + assert db.lastrowid == 42 + + def test_rowcount_property(self): + from django_cf.db.base_engine import CFDatabase, CFResult + + mock_wrapper = MagicMock() + mock_result = CFResult([]) + mock_result.set_rowcount(5) + mock_wrapper.run_query.return_value = mock_result + db = CFDatabase(mock_wrapper) + + db.execute('UPDATE test SET name = "new"') + + assert db.rowcount == 5 + + +class TestCFDatabaseFeatures: + def test_transactions_disabled(self): + from django_cf.db.base_engine import CFDatabaseFeatures + + features = CFDatabaseFeatures(MagicMock()) + + assert features.atomic_transactions is False + assert features.supports_transactions is False + + def test_savepoints_disabled(self): + from django_cf.db.base_engine import CFDatabaseFeatures + + assert CFDatabaseFeatures(MagicMock()).can_release_savepoints is False + + def test_constraint_checks_disabled(self): + from django_cf.db.base_engine import CFDatabaseFeatures + + features = CFDatabaseFeatures(MagicMock()) + + assert features.can_defer_constraint_checks is False + assert features.supports_pragma_foreign_key_check is False + + def test_max_query_params(self): + from django_cf.db.base_engine import CFDatabaseFeatures + + assert CFDatabaseFeatures(MagicMock()).max_query_params == 100 + + def test_bulk_insert_enabled(self): + from django_cf.db.base_engine import CFDatabaseFeatures + + features = CFDatabaseFeatures(MagicMock()) + + assert features.has_bulk_insert is True + assert features.can_return_columns_from_insert is True + + +class TestCFDatabaseWrapper: + def test_get_database_version(self): + from django_cf.db.base_engine import CFDatabaseWrapper + + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): + wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) + assert wrapper.get_database_version() == (4,) + + def test_close_does_nothing(self): + from django_cf.db.base_engine import CFDatabaseWrapper + + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): + wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) + assert wrapper.close() is None + + def test_savepoint_not_allowed(self): + from django_cf.db.base_engine import CFDatabaseWrapper + + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): + wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) + assert wrapper._savepoint_allowed() is False + + def test_is_usable_always_true(self): + from django_cf.db.base_engine import CFDatabaseWrapper + + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): + wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) + assert wrapper.is_usable() is True + + def test_run_query_not_implemented(self): + from django_cf.db.base_engine import CFDatabaseWrapper + + with patch.object(CFDatabaseWrapper, "__init__", lambda self, *args: None): + wrapper = CFDatabaseWrapper.__new__(CFDatabaseWrapper) + with pytest.raises(NotImplementedError): + wrapper.run_query("SELECT 1") + + +class TestCFDatabaseOperations: + def test_bulk_insert_sql(self): + from django_cf.db.base_engine import CFDatabaseOperations + + ops = CFDatabaseOperations(MagicMock()) + + fields = ["id", "name"] + placeholder_rows = [("%s", "%s"), ("%s", "%s")] + assert ( + ops.bulk_insert_sql(fields, placeholder_rows) == "VALUES (%s, %s), (%s, %s)" + ) + + def test_last_executed_query_no_params(self): + from django_cf.db.base_engine import CFDatabaseOperations + + ops = CFDatabaseOperations(MagicMock()) + sql = "SELECT * FROM test" + + assert ops.last_executed_query(None, sql, None) == sql + + def test_last_executed_query_with_params(self): + from django_cf.db.base_engine import CFDatabaseOperations + + mock_wrapper = MagicMock() + mock_wrapper.connection = MagicMock() + mock_wrapper.connection.cursor.return_value = MagicMock() + ops = CFDatabaseOperations(mock_wrapper) + + result = ops.last_executed_query(None, "SELECT * FROM test WHERE id = %s", [1]) + + assert isinstance(result, str) + assert "SELECT * FROM test WHERE id" in result + + def test_last_executed_query_catches_formatting_errors(self): + from django_cf.db.base_engine import CFDatabaseOperations + + ops = CFDatabaseOperations(MagicMock()) + sql = "SELECT * FROM test WHERE id = %s AND name = %s" + + assert ops.last_executed_query(None, sql, (1,)) == sql + + def test_last_executed_query_does_not_catch_keyboard_interrupt(self): + from django_cf.db.base_engine import CFDatabaseOperations + + ops = CFDatabaseOperations(MagicMock()) + + class BadStr: + def __str__(self): + raise KeyboardInterrupt() + + def __format__(self, spec): + raise KeyboardInterrupt() + + ops._quote_params_for_last_executed_query = lambda params: (BadStr(),) + + with pytest.raises(KeyboardInterrupt): + ops.last_executed_query(None, "SELECT * FROM test WHERE id = %s", [1]) + + +class TestCFSQLCompiler: + def test_replace_date_trunc_functions_year(self): + from django_cf.db.base_engine import CFSQLCompiler + + compiler = CFSQLCompiler.__new__(CFSQLCompiler) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('year', created_at) FROM orders" + ) + + assert 'STRFTIME("%Y-01-01", created_at)' in result + assert "django_date_trunc" not in result + + def test_replace_date_trunc_functions_month(self): + from django_cf.db.base_engine import CFSQLCompiler + + compiler = CFSQLCompiler.__new__(CFSQLCompiler) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('month', created_at) FROM orders" + ) + + assert 'STRFTIME("%Y-%m-01", created_at)' in result + + def test_replace_date_trunc_functions_day(self): + from django_cf.db.base_engine import CFSQLCompiler + + compiler = CFSQLCompiler.__new__(CFSQLCompiler) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('day', created_at) FROM orders" + ) + + assert "DATE(created_at)" in result + + def test_replace_date_trunc_functions_hour(self): + from django_cf.db.base_engine import CFSQLCompiler + + compiler = CFSQLCompiler.__new__(CFSQLCompiler) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('hour', created_at) FROM orders" + ) + + assert 'STRFTIME("%Y-%m-%d %H:00:00", created_at)' in result + + def test_replace_date_trunc_functions_unknown_kind(self): + from django_cf.db.base_engine import CFSQLCompiler + + compiler = CFSQLCompiler.__new__(CFSQLCompiler) + result = compiler._replace_date_trunc_functions( + "SELECT django_date_trunc('unknown', created_at) FROM orders" + ) + + assert "django_date_trunc('unknown', created_at)" in result + + +class TestD1DatabaseWrapperProcessQuery: + def test_process_query_no_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s", None + ) + + assert result_query == "SELECT * FROM users WHERE id = ?" + assert result_params is None + + def test_process_query_with_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s AND name = %s", [1, "test"] + ) + + assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" + assert result_params == [1, "test"] + + def test_process_query_null_param_replaced_with_literal(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name, email) VALUES (%s, %s)", ["test", None] + ) + + assert "null" in result_query + assert result_params == ["test"] + + def test_process_query_all_null_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name, email) VALUES (%s, %s)", [None, None] + ) + + assert result_query == "INSERT INTO users (name, email) VALUES (null, null)" + assert result_params == [] + + def test_process_query_mixed_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "UPDATE users SET name = %s, email = %s, age = %s WHERE id = %s", + ["test", None, 25, None], + ) + + assert result_params == ["test", 25] + assert result_query.count("?") == 2 + assert result_query.count("null") == 2 + + def test_process_query_with_defer_foreign_keys(self): + result = make_d1_wrapper(True).process_query( + "INSERT INTO users (name) VALUES (%s)", ["test"] + ) + + assert "PRAGMA defer_foreign_keys = on" in result + assert "PRAGMA defer_foreign_keys = off" in result + + def test_process_query_date_trunc_replacement(self): + wrapper = make_d1_wrapper() + + result_query, _ = wrapper.process_query( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", + ["year", "UTC", "UTC"], + ) + + assert "django_date_trunc" not in result_query + assert "CASE" in result_query or "STRFTIME" in result_query + + +class TestD1DatabaseWrapperConfiguration: + def test_vendor_and_display_name(self): + from django_cf.db.backends.d1.base import DatabaseWrapper + + assert DatabaseWrapper.vendor == "cloudflare_d1" + assert DatabaseWrapper.display_name == "D1" + + +class TestD1GetConnectionParams: + def test_missing_binding_raises_error(self): + from django.core.exceptions import ImproperlyConfigured + + wrapper = make_d1_wrapper() + wrapper.settings_dict = {"CLOUDFLARE_BINDING": None} + + with pytest.raises(ImproperlyConfigured) as exc_info: + wrapper.get_connection_params() + + assert "CLOUDFLARE_BINDING" in str(exc_info.value) + + def test_valid_binding_returns_params(self): + wrapper = make_d1_wrapper() + wrapper.settings_dict = {"CLOUDFLARE_BINDING": "MY_DB"} + + assert wrapper.get_connection_params() == {"binding": "MY_DB"} + + +class TestD1ExceptionHandling: + @pytest.mark.xfail( + reason=( + "The `except Exception: raise Error(Error.new().stack)` handler in `run_query` swallows this and re-raises a JS Error. Removing that handler is a separate change; these pass once it lands." + ), + strict=True, + ) + def test_run_query_lets_binding_errors_propagate(self, monkeypatch): + wrapper = make_d1_wrapper() + error = RuntimeError("binding boom") + monkeypatch.setattr( + workers, + "env", + SimpleNamespace(DB=FakeD1Binding(FakeD1Statement(raw_error=error))), + ) + + with pytest.raises(RuntimeError, match="binding boom"): + wrapper.run_query("SELECT * FROM test") + + +class TestD1RunQuery: + def test_read_query_returns_rows(self, monkeypatch): + wrapper = make_d1_wrapper() + statement = FakeD1Statement(raw_result=[[1, "hello"], [2, "world"]]) + binding = FakeD1Binding(statement) + monkeypatch.setattr(workers, "env", SimpleNamespace(DB=binding)) + + result = wrapper.run_query("SELECT * FROM test WHERE id = %s", [1]) + + assert list(result) == [(1, "hello"), (2, "world")] + assert statement.bound_params == (1,) + assert binding.prepared_queries == ["SELECT * FROM test WHERE id = ?"] + + def test_write_query_returns_meta(self, monkeypatch): + wrapper = make_d1_wrapper() + statement = FakeD1Statement( + all_result={ + "results": [["ok"]], + "meta": {"rows_read": 1, "rows_written": 2, "last_row_id": 9}, + } + ) + monkeypatch.setattr( + workers, "env", SimpleNamespace(DB=FakeD1Binding(statement)) + ) + + result = wrapper.run_query("INSERT INTO test VALUES (%s)", ["x"]) + + assert list(result) == [("ok",)] + assert result.rowcount == 2 + assert result.lastrowid == 9 + + +class TestD1ParameterHandling: + def test_empty_params_list(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query("SELECT * FROM users", []) + + assert result_query == "SELECT * FROM users" + assert result_params == [] + + def test_special_characters_in_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name) VALUES (%s)", ["test'; DROP TABLE users; --"] + ) + + assert result_params == ["test'; DROP TABLE users; --"] + assert "?" in result_query + + def test_unicode_params(self): + wrapper = make_d1_wrapper() + + _, result_params = wrapper.process_query( + "INSERT INTO users (name) VALUES (%s)", [""] + ) + + assert result_params == [""] + + def test_large_number_of_params(self): + wrapper = make_d1_wrapper() + + placeholders = ", ".join(["%s"] * 50) + result_query, result_params = wrapper.process_query( + f"INSERT INTO test VALUES ({placeholders})", list(range(50)) + ) + + assert result_query.count("?") == 50 + assert len(result_params) == 50 + + +class TestDODatabaseWrapperProcessQuery: + def test_process_query_no_params(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s", None + ) + + assert result_query == "SELECT * FROM users WHERE id = ?" + assert result_params is None + + def test_process_query_with_params(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s AND name = %s", [1, "test"] + ) + + assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" + assert result_params == [1, "test"] + + def test_process_query_null_param_replaced_with_literal(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name, email) VALUES (%s, %s)", ["test", None] + ) + + assert "null" in result_query + assert result_params == ["test"] + + def test_process_query_with_defer_foreign_keys(self): + result = make_do_wrapper(True).process_query( + "INSERT INTO users (name) VALUES (%s)", ["test"] + ) + + assert "PRAGMA defer_foreign_keys = on" in result + assert "PRAGMA defer_foreign_keys = off" in result + + +class TestDODatabaseWrapperConfiguration: + def test_vendor_and_display_name(self): + from django_cf.db.backends.do.base import DatabaseWrapper + + assert DatabaseWrapper.vendor == "cloudflare_durable_objects" + assert DatabaseWrapper.display_name == "DO" + + def test_get_connection_params_returns_empty(self): + assert make_do_wrapper().get_connection_params() == {} + + +class TestDOMissingDateTruncBug: + def test_do_process_query_missing_date_trunc(self): + d1_result, _ = make_d1_wrapper().process_query( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", + ["year", "UTC", "UTC"], + ) + do_result, _ = make_do_wrapper().process_query( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", + ["year", "UTC", "UTC"], + ) + + assert "django_date_trunc" not in d1_result + assert "django_date_trunc" in do_result + + +class TestDOStorageInitialization: + def test_storage_module_exists(self): + from django_cf.db.backends.do import storage + + storage.set_storage(None) + assert storage.get_storage() is None + + def test_run_query_uses_configured_storage(self): + from django_cf.db.backends.do import storage + + fake_storage = FakeDOStorage(FakeDOStatement([[1, "ok"]], rows_read=1)) + storage.set_storage(fake_storage) + + result = make_do_wrapper().run_query("SELECT * FROM test WHERE id = %s", [1]) + + assert list(result) == [(1, "ok")] + assert fake_storage.calls == [("SELECT * FROM test WHERE id = ?", (1,))] + + +class TestDOQueryExecution: + def test_read_query_uses_raw(self): + from django_cf.db.backends.do import storage + + fake_storage = FakeDOStorage(FakeDOStatement([], rows_read=0, rows_written=0)) + storage.set_storage(fake_storage) + + result = make_do_wrapper().run_query("SELECT * FROM users") + + assert result.fetchall() == [] + + +class TestDOExceptionHandling: + def test_run_query_lets_binding_errors_propagate(self): + from django_cf.db.backends.do import storage + + storage.set_storage(FakeDOStorage(error=RuntimeError("storage boom"))) + + with pytest.raises(RuntimeError, match="storage boom"): + make_do_wrapper().run_query("SELECT * FROM test") + + +class TestDOParamConversion: + def test_boolean_true_not_converted_in_process_query(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO test (active) VALUES (%s)", [True] + ) + + assert result_query == "INSERT INTO test (active) VALUES (?)" + assert result_params == [True] + + def test_multiple_none_params_order_preserved(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO test (a, b, c, d) VALUES (%s, %s, %s, %s)", + [1, None, 2, None], + ) + + assert result_params == [1, 2] + assert result_query == "INSERT INTO test (a, b, c, d) VALUES (?, null, ?, null)" + + +class TestDOPragmaReturnTypeBug: + def test_pragma_returns_string_not_tuple(self): + result = make_do_wrapper(True).process_query( + "INSERT INTO users (name) VALUES (%s)", ["test"] + ) + + assert isinstance(result, str) diff --git a/packages/django-cf/tests/in_worker/worker/src/test_middleware_wsgi.py b/packages/django-cf/tests/in_worker/worker/src/test_middleware_wsgi.py new file mode 100644 index 00000000..69d4de7a --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_middleware_wsgi.py @@ -0,0 +1,630 @@ +"""Middleware and WSGI tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +import base64 +import importlib +import json +import time +from datetime import datetime +from io import BytesIO +from types import SimpleNamespace +from unittest.mock import MagicMock +from uuid import uuid4 + +import django.conf +import pytest +import workers + + +def base64url_encode(data): + if isinstance(data, str): + data = data.encode("utf-8") + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") + + +def create_jwt_parts(header, payload): + return base64url_encode(json.dumps(header)), base64url_encode(json.dumps(payload)) + + +def create_test_jwt(payload, kid="test-key-1"): + header = {"alg": "RS256", "typ": "JWT", "kid": kid} + header_b64, payload_b64 = create_jwt_parts(header, payload) + signature_b64 = base64url_encode(b"fake-signature") + return f"{header_b64}.{payload_b64}.{signature_b64}" + + +def make_request(path="/api/resource/"): + return SimpleNamespace(META={}, COOKIES={}, path=path, session=MagicMock()) + + +def middleware_module(): + return importlib.import_module("django_cf.middleware.CloudflareAccessMiddleware") + + +def make_middleware( + monkeypatch, + *, + aud: str | None = "test-aud-12345", + team_name: str | None = "testteam", + exempt_paths=None, + cache_timeout=3600, +): + from django.conf import settings + + exempt_paths = [] if exempt_paths is None else exempt_paths + monkeypatch.setattr(settings, "CLOUDFLARE_ACCESS_AUD", aud, raising=False) + monkeypatch.setattr( + settings, "CLOUDFLARE_ACCESS_TEAM_NAME", team_name, raising=False + ) + monkeypatch.setattr( + settings, "CLOUDFLARE_ACCESS_EXEMPT_PATHS", exempt_paths, raising=False + ) + monkeypatch.setattr( + settings, "CLOUDFLARE_ACCESS_CACHE_TIMEOUT", cache_timeout, raising=False + ) + return middleware_module().CloudflareAccessMiddleware(lambda request: request) + + +def unique_name(prefix: str) -> str: + return f"{prefix}-{uuid4().hex}" + + +def make_r2_storage(**kwargs): + from django_cf.storage.r2 import R2Storage + + return R2Storage(**kwargs) + + +def make_live_r2_storage(**kwargs): + pyodide_run_sync = None + try: + from pyodide.ffi import run_sync as pyodide_run_sync + except ImportError: + pytest.skip("R2Storage requires pyodide.ffi.run_sync (JSPI)") + assert pyodide_run_sync is not None + + storage = make_r2_storage(**kwargs) + storage._bucket = workers.env.BUCKET + storage._run_sync = pyodide_run_sync + return storage + + +def save_bytes(storage, name: str, data: bytes) -> str: + return storage._save(name, BytesIO(data)) + + +class TestJWTExtraction: + def test_extract_jwt_from_header(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = "header-jwt-token" + + assert middleware._extract_jwt_token(request) == "header-jwt-token" + + def test_extract_jwt_from_cookie(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.COOKIES["CF_Authorization"] = "cookie-jwt-token" + + assert middleware._extract_jwt_token(request) == "cookie-jwt-token" + + def test_extract_jwt_from_lowercase_cookie(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.COOKIES["cf_authorization"] = "lowercase-cookie-jwt-token" + + assert middleware._extract_jwt_token(request) == "lowercase-cookie-jwt-token" + + def test_extract_jwt_no_token(self, monkeypatch): + assert make_middleware(monkeypatch)._extract_jwt_token(make_request()) is None + + def test_no_broken_dash_header_lookup(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.META["HTTP-CF-ACCESS-JWT-ASSERTION"] = "broken-token" + + assert middleware._extract_jwt_token(request) is None + + request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = "good-token" + assert middleware._extract_jwt_token(request) == "good-token" + + def test_header_takes_precedence_over_cookie(self, monkeypatch): + middleware = make_middleware(monkeypatch) + request = make_request() + request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = "header-token" + request.COOKIES["CF_Authorization"] = "cookie-token" + + assert middleware._extract_jwt_token(request) == "header-token" + + +class TestTeamNameExtraction: + def test_extract_team_name_from_valid_jwt(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + jwt_token = create_test_jwt( + { + "iss": "https://myteam.cloudflareaccess.com", + "email": "user@example.com", + } + ) + + assert middleware._extract_team_name_from_jwt(jwt_token) == "myteam" + + def test_extract_team_name_invalid_issuer_format(self, monkeypatch): + middleware = make_middleware(monkeypatch) + jwt_token = create_test_jwt( + {"iss": "https://other-issuer.com", "email": "user@example.com"} + ) + + assert middleware._extract_team_name_from_jwt(jwt_token) is None + + def test_extract_team_name_missing_issuer(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert ( + middleware._extract_team_name_from_jwt( + create_test_jwt({"email": "user@example.com"}) + ) + is None + ) + + def test_extract_team_name_malformed_jwt(self, monkeypatch): + assert ( + make_middleware(monkeypatch)._extract_team_name_from_jwt("invalid.jwt") + is None + ) + + +class TestExemptPaths: + def test_exempt_path_matches(self, monkeypatch): + middleware = make_middleware(monkeypatch, exempt_paths=["/health/", "/public/"]) + + assert middleware._is_exempt_path("/health/") is True + assert middleware._is_exempt_path("/health/check") is True + assert middleware._is_exempt_path("/public/") is True + assert middleware._is_exempt_path("/public/resource") is True + + def test_non_exempt_path(self, monkeypatch): + middleware = make_middleware(monkeypatch, exempt_paths=["/health/", "/public/"]) + + assert middleware._is_exempt_path("/api/") is False + assert middleware._is_exempt_path("/admin/") is False + assert middleware._is_exempt_path("/") is False + + def test_empty_exempt_paths(self, monkeypatch): + assert make_middleware(monkeypatch)._is_exempt_path("/any/path/") is False + + +class TestMiddlewareInitialization: + def test_init_with_aud_only(self, monkeypatch): + middleware = make_middleware(monkeypatch, team_name=None) + + assert middleware.aud == "test-aud-12345" + assert middleware.team_name is None + assert middleware.team_domain is None + assert middleware.certs_url is None + + def test_init_with_team_name_only(self, monkeypatch): + middleware = make_middleware(monkeypatch, aud=None) + + assert middleware.aud is None + assert middleware.team_name == "testteam" + assert middleware.team_domain == "testteam.cloudflareaccess.com" + assert ( + middleware.certs_url + == "https://testteam.cloudflareaccess.com/cdn-cgi/access/certs" + ) + + def test_init_with_both_settings(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert middleware.aud == "test-aud-12345" + assert middleware.team_name == "testteam" + + def test_init_without_required_settings(self, monkeypatch): + with pytest.raises(ValueError) as exc_info: + make_middleware(monkeypatch, aud=None, team_name=None) + + assert "Either CLOUDFLARE_ACCESS_AUD or CLOUDFLARE_ACCESS_TEAM_NAME" in str( + exc_info.value + ) + + +class TestBase64UrlDecode: + def test_base64url_decode_standard(self, monkeypatch): + middleware = make_middleware(monkeypatch) + encoded = base64url_encode(b"hello world") + + assert middleware._base64url_decode(encoded) == b"hello world" + + def test_base64url_decode_with_padding_needed(self, monkeypatch): + assert make_middleware(monkeypatch)._base64url_decode("YWI") == b"ab" + + +class TestJWTDecodeAndVerify: + def test_decode_jwt_invalid_format(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert ( + middleware._decode_and_verify_jwt( + "only.two", {"kid": "test-key", "n": 123, "e": 65537} + ) + is None + ) + + def test_decode_jwt_key_id_mismatch(self, monkeypatch): + middleware = make_middleware(monkeypatch) + jwt_token = create_test_jwt({"email": "test@example.com"}, kid="key-1") + + assert ( + middleware._decode_and_verify_jwt( + jwt_token, {"kid": "key-2", "n": 123, "e": 65537} + ) + is None + ) + + def test_decode_jwt_unsupported_algorithm(self, monkeypatch): + middleware = make_middleware(monkeypatch) + header = {"alg": "HS256", "typ": "JWT", "kid": "test-key"} + payload = {"email": "test@example.com"} + header_b64, payload_b64 = create_jwt_parts(header, payload) + jwt_token = f"{header_b64}.{payload_b64}.{base64url_encode(b'fake-signature')}" + + assert ( + middleware._decode_and_verify_jwt( + jwt_token, {"kid": "test-key", "n": 123, "e": 65537} + ) + is None + ) + + def test_decode_jwt_expired_token(self, monkeypatch): + middleware = make_middleware(monkeypatch) + jwt_token = create_test_jwt( + {"email": "test@example.com", "exp": int(time.time()) - 3600}, + kid="test-key", + ) + + assert ( + middleware._decode_and_verify_jwt( + jwt_token, {"kid": "test-key", "n": 123, "e": 65537} + ) + is None + ) + + def test_decode_jwt_not_yet_valid(self, monkeypatch): + middleware = make_middleware(monkeypatch) + jwt_token = create_test_jwt( + {"email": "test@example.com", "nbf": int(time.time()) + 3600}, + kid="test-key", + ) + + assert ( + middleware._decode_and_verify_jwt( + jwt_token, {"kid": "test-key", "n": 123, "e": 65537} + ) + is None + ) + + +class TestRSAKeyProcessing: + def test_process_rsa_key_valid(self, monkeypatch): + middleware = make_middleware(monkeypatch) + result = middleware._process_rsa_key( + { + "kty": "RSA", + "kid": "test-key-1", + "n": base64url_encode(b"\x00\x01\x00\x01"), + "e": base64url_encode(b"\x01\x00\x01"), + } + ) + + assert result is not None + assert result["kid"] == "test-key-1" + assert "n" in result + assert "e" in result + + def test_process_rsa_key_missing_n(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert ( + middleware._process_rsa_key( + { + "kty": "RSA", + "kid": "test-key-1", + "e": base64url_encode(b"\x01\x00\x01"), + } + ) + is None + ) + + def test_process_rsa_key_missing_e(self, monkeypatch): + middleware = make_middleware(monkeypatch) + + assert ( + middleware._process_rsa_key( + { + "kty": "RSA", + "kid": "test-key-1", + "n": base64url_encode(b"\x00\x01\x00\x01"), + } + ) + is None + ) + + +class TestUserProvisioning: + def test_get_or_create_user_creates_user(self, monkeypatch): + module = middleware_module() + middleware = make_middleware(monkeypatch) + + created = {} + + class FakeDoesNotExist(Exception): + pass + + class FakeManager: + def get(self, email): + raise FakeDoesNotExist() + + def create_user(self, **kwargs): + created.update(kwargs) + return SimpleNamespace(**kwargs) + + fake_user_model = SimpleNamespace( + objects=FakeManager(), DoesNotExist=FakeDoesNotExist + ) + monkeypatch.setattr(module, "User", fake_user_model) + + user = middleware._get_or_create_user("user@example.com", "Test User") + + assert user.email == "user@example.com" + assert created["username"] == "user@example.com" + assert created["first_name"] == "Test" + assert created["last_name"] == "User" + assert created["is_active"] is True + + def test_get_or_create_user_updates_existing_name(self, monkeypatch): + module = middleware_module() + middleware = make_middleware(monkeypatch) + existing = SimpleNamespace( + email="user@example.com", + first_name="Old", + last_name="Name", + save=MagicMock(), + ) + existing.get_full_name = ( + lambda: f"{existing.first_name} {existing.last_name}".strip() + ) + + class FakeManager: + def get(self, email): + return existing + + fake_user_model = SimpleNamespace(objects=FakeManager(), DoesNotExist=Exception) + monkeypatch.setattr(module, "User", fake_user_model) + + user = middleware._get_or_create_user("user@example.com", "New Name") + + assert user is existing + assert existing.first_name == "New" + assert existing.last_name == "Name" + existing.save.assert_called_once_with() + + +class TestDjangoCFErrorMessages: + def test_djangocf_get_app_error_message(self): + from django_cf import DjangoCF + + cf = DjangoCF() + with pytest.raises(NotImplementedError) as exc_info: + cf.get_app() + + assert ( + str(exc_info.value) == "Please implement get_app in your django_cf worker" + ) + assert "implement implement" not in str(exc_info.value) + + +class TestR2StorageInitialization: + def test_init_default_values(self): + storage = make_r2_storage() + + assert storage.binding == "BUCKET" + assert storage.location == "" + assert storage.allow_overwrite is False + assert storage._bucket is None + + def test_init_custom_values(self): + storage = make_r2_storage( + binding="MY_BUCKET", location="uploads/", allow_overwrite=True + ) + + assert storage.binding == "MY_BUCKET" + assert storage.location == "uploads" + assert storage.allow_overwrite is True + + def test_init_strips_slashes_from_location(self): + assert make_r2_storage(location="/uploads/files/").location == "uploads/files" + + +class TestR2StoragePathsAndUrls: + def test_full_path_no_location(self): + assert ( + make_r2_storage(location="")._full_path("test/file.txt") == "test/file.txt" + ) + + def test_full_path_with_location(self): + assert ( + make_r2_storage(location="uploads")._full_path("test/file.txt") + == "uploads/test/file.txt" + ) + + def test_url_raises_without_media_url(self, monkeypatch): + storage = make_r2_storage() + + monkeypatch.setattr(django.conf, "settings", SimpleNamespace()) + + with pytest.raises(ValueError, match="MEDIA_URL must be configured"): + storage.url("file.txt") + + def test_url_raises_with_empty_media_url(self, monkeypatch): + storage = make_r2_storage() + + monkeypatch.setattr(django.conf, "settings", SimpleNamespace(MEDIA_URL="")) + + with pytest.raises(ValueError, match="MEDIA_URL must be configured"): + storage.url("file.txt") + + def test_url_constructs_correct_path(self, monkeypatch): + storage = make_r2_storage(location="uploads") + monkeypatch.setattr( + django.conf, "settings", SimpleNamespace(MEDIA_URL="/media/") + ) + + assert storage.url("file.txt") == "/media/uploads/file.txt" + + +class TestR2StorageMissingObjects: + def test_read_returns_none_on_not_found(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-read")) + + assert storage._read("nonexistent.txt") is None + + def test_exists_returns_false_on_not_found(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-exists")) + + assert storage.exists("nonexistent.txt") is False + + def test_size_returns_zero_on_not_found(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-size")) + + assert storage.size("nonexistent.txt") == 0 + + def test_get_modified_time_returns_now_for_missing_file(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-modified")) + + result = storage.get_modified_time("nonexistent.txt") + + assert abs((datetime.now() - result).total_seconds()) < 5 + + +class TestR2StoragePersistence: + def test_save_with_file_like_object(self): + storage = make_live_r2_storage(location=unique_name("r2-save-filelike")) + name = unique_name("test") + ".txt" + + assert save_bytes(storage, name, b"test content") == name + assert storage._read(name) == b"test content" + + def test_listdir_with_empty_path(self): + storage = make_live_r2_storage(location=unique_name("r2-empty-listdir")) + + directories, files = storage.listdir("") + + assert directories == [] + assert files == [] + + def test_listdir_scopes_to_directory_prefix(self): + storage = make_live_r2_storage(location=unique_name("r2-listdir-scope")) + + save_bytes(storage, "uploads/file.txt", b"file") + save_bytes(storage, "uploads2/other.txt", b"other") + + directories, files = storage.listdir("uploads") + + assert directories == [] + assert files == ["file.txt"] + + def test_get_available_name_raises_on_too_long(self): + storage = make_r2_storage() + + with pytest.raises(Exception, match="too long"): + storage.get_available_name("a" * 300, max_length=100) + + def test_get_available_name_returns_original_when_allow_overwrite(self): + storage = make_r2_storage(allow_overwrite=True) + + assert storage.get_available_name("file.txt") == "file.txt" + + def test_get_available_name_returns_original_when_not_exists(self): + storage = make_live_r2_storage(location=unique_name("r2-available-original")) + + assert storage.get_available_name("file.txt") == "file.txt" + + def test_get_available_name_increments_counter(self): + storage = make_live_r2_storage(location=unique_name("r2-available-counter")) + + save_bytes(storage, "file.txt", b"first") + save_bytes(storage, "file_1.txt", b"second") + + assert storage.get_available_name("file.txt") == "file_2.txt" + + def test_get_accessed_time_returns_modified_time(self): + storage = make_live_r2_storage(location=unique_name("r2-accessed-time")) + expected = datetime(2026, 1, 1, 12, 0, 0) + storage.get_modified_time = lambda name: expected + + assert storage.get_accessed_time("file.txt") == expected + + def test_get_created_time_returns_modified_time(self): + storage = make_live_r2_storage(location=unique_name("r2-created-time")) + expected = datetime(2026, 1, 1, 12, 0, 0) + storage.get_modified_time = lambda name: expected + + assert storage.get_created_time("file.txt") == expected + + +class TestR2FileClass: + def test_r2file_read_mode(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-read")) + name = unique_name("read") + ".txt" + save_bytes(storage, name, b"test content") + + assert R2File(name, storage, mode="rb").read() == b"test content" + + def test_r2file_write_raises_in_read_mode(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-readonly")) + name = unique_name("readonly") + ".txt" + save_bytes(storage, name, b"existing") + r2file = R2File(name, storage, mode="rb") + + with pytest.raises(AttributeError, match="not opened for writing"): + r2file.write(b"new content") + + def test_r2file_write_allowed_in_write_mode(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-write")) + r2file = R2File(unique_name("write") + ".txt", storage, mode="wb") + + assert r2file.write(b"new content") == 11 + + def test_r2file_close(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-close")) + name = unique_name("close") + ".txt" + save_bytes(storage, name, b"content") + r2file = R2File(name, storage) + _ = r2file.file + + assert r2file._file is not None + + r2file.close() + assert r2file._file.closed is True + + def test_djangocf_durable_object_get_app_error_message(self): + from django_cf import DjangoCFDurableObject + + with pytest.raises(NotImplementedError) as exc_info: + DjangoCFDurableObject.get_app(None) + + assert ( + str(exc_info.value) == "Please implement get_app in your django_cf worker" + ) + assert "implement implement" not in str(exc_info.value) diff --git a/packages/django-cf/tests/in_worker/worker/src/worker.py b/packages/django-cf/tests/in_worker/worker/src/worker.py new file mode 100644 index 00000000..962bc9cc --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/worker.py @@ -0,0 +1,182 @@ +# pyright: reportMissingImports=false + +import asyncio +import contextlib +import importlib.util +import io +from pathlib import Path +from urllib.parse import urlparse + +import django +import django.conf +import pytest +from django.http import HttpResponse, JsonResponse +from workers import Response, WorkerEntrypoint + +BASE_DIR = Path(__file__).parent + +if not django.conf.settings.configured: + django.conf.settings.configure( + DEBUG=False, + SECRET_KEY="django-cf-host-only-tests", + ROOT_URLCONF=__name__, + ALLOWED_HOSTS=["*"], + INSTALLED_APPS=[ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + ], + MIDDLEWARE=[ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + ], + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + }, + CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + }, + SESSION_ENGINE="django.contrib.sessions.backends.signed_cookies", + USE_TZ=True, + TIME_ZONE="UTC", + DEFAULT_AUTO_FIELD="django.db.models.AutoField", + ) + +django.setup() + +from django_cf import handle_wsgi # noqa: E402 + +urlpatterns = [] + + +def _wsgi_header_echo_app(environ, start_response): + payload = { + "cf_access": environ.get("HTTP_CF_ACCESS_JWT_ASSERTION"), + "custom": environ.get("HTTP_X_CUSTOM_HEADER"), + "content_type": environ.get("CONTENT_TYPE"), + "content_length": environ.get("CONTENT_LENGTH"), + } + return JsonResponse(payload) + + +def _wsgi_body_echo_app(environ, start_response): + length = int(environ.get("CONTENT_LENGTH") or 0) + body = environ["wsgi.input"].read(length) + return HttpResponse(body, content_type="application/octet-stream") + + +class ResultCollector: + def __init__(self): + self.results = {} + + @staticmethod + def _key(item): + normalized = [] + if item.cls is not None: + normalized.append(item.cls.__name__) + name = getattr(item, "originalname", None) or item.name + normalized.append(name[len("test_") :] if name.startswith("test_") else name) + return "__".join(normalized) + + @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 Default(WorkerEntrypoint): + async def fetch(self, request): + 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}) + if path == "/wsgi/headers": + return await handle_wsgi(request, _wsgi_header_echo_app) + if path == "/wsgi/body": + return await handle_wsgi(request, _wsgi_body_echo_app) + return Response.json({"error": "not found"}, status=404) + + 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() + saved_loop = asyncio.events._get_running_loop() + output = io.StringIO() + try: + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + exit_code = pytest.main( + ["--pyargs", module, "-p", "no:cacheprovider"], + plugins=[collector, EnvPlugin(self.env)], + ) + finally: + asyncio.events._set_running_loop(saved_loop) + if exit_code != 0 and not collector.results: + return Response.json( + { + "__session__": { + "status": "error", + "error": f"pytest exit code {exit_code}", + "traceback": output.getvalue(), + } + }, + status=500, + ) + return Response.json(collector.results) diff --git a/packages/django-cf/tests/in_worker/worker/wrangler.jsonc b/packages/django-cf/tests/in_worker/worker/wrangler.jsonc new file mode 100644 index 00000000..a26997b3 --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "django-cf-host-only-tests", + "main": "src/worker.py", + "compatibility_date": "%COMPAT_DATE", + "compatibility_flags": ["python_workers"], + "r2_buckets": [ + { + "binding": "BUCKET", + "bucket_name": "django-test-bucket", + "preview_bucket_name": "django-test-bucket-preview" + } + ] +} diff --git a/packages/django-cf/tests/in_worker_harness.py b/packages/django-cf/tests/in_worker_harness.py new file mode 100644 index 00000000..b5d191e3 --- /dev/null +++ b/packages/django-cf/tests/in_worker_harness.py @@ -0,0 +1,289 @@ +"""Helpers for django-cf suites that execute pytest inside workerd.""" + +# pyright: reportMissingImports=false, reportMissingModuleSource=false + +import ast +import functools +import os +import shutil +import socket +import subprocess +import time +from collections.abc import Callable, Generator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, TypedDict + +import pytest +import requests + +TEST_DIR: Path = Path(__file__).parent +PACKAGE_DIR: Path = TEST_DIR.parent +WORKERS_PY: Path = PACKAGE_DIR.parent / "cli" +WORKERS_RUNTIME_SDK: Path = PACKAGE_DIR.parent / "runtime-sdk" / "src" +DJANGO_CF_SRC: Path = PACKAGE_DIR / "django_cf" + +DEV_STARTUP_TIMEOUT: int = 120 +DEV_POLL_INTERVAL: float = 0.5 +SUITE_CONNECT_TIMEOUT: int = 10 +SUITE_READ_TIMEOUT: int = 300 + + +@dataclass(frozen=True) +class CompatConfig: + compat_date: str + python_version: str + extra_compat_flags: list[str] = field(default_factory=list) + + +COMPAT_CONFIGS: list[CompatConfig] = [ + CompatConfig( + compat_date="2025-09-01", + python_version="3.12", + extra_compat_flags=[ + "enable_python_external_sdk", + "python_process_pth_files", + "python_request_headers_preserve_commas", + ], + ), + CompatConfig( + compat_date="2026-01-01", + python_version="3.13", + extra_compat_flags=[ + "enable_python_external_sdk", + "python_process_pth_files", + "python_request_headers_preserve_commas", + ], + ), + CompatConfig( + compat_date="2026-07-01", + python_version="3.14", + extra_compat_flags=["python_workers_20260610", "experimental"], + ), +] + + +def replace_compat_date(file: Path, compat_date: str) -> None: + file.write_text(file.read_text().replace("%COMPAT_DATE", compat_date)) + + +def inject_compat_flags(file: Path, extra_flags: list[str]) -> None: + if not extra_flags: + return + content = file.read_text() + for flag in extra_flags: + content = content.replace('"python_workers"', f'"python_workers", "{flag}"') + file.write_text(content) + + +class InWorkerTestResult(TypedDict): + status: Literal["passed", "failed", "error", "skipped"] + error: str + traceback: str + reason: str + + +SuiteResults = dict[str, InWorkerTestResult] + + +def get_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def wait_for_ready( + process: subprocess.Popen[bytes], base_url: str, log_path: Path +) -> None: + deadline = time.time() + DEV_STARTUP_TIMEOUT + while time.time() < deadline: + if process.poll() is not None: + pytest.fail( + f"pywrangler dev exited early with code {process.returncode}\n" + f"stdout: {log_path.read_text(errors='replace')}" + ) + try: + resp = requests.get(f"{base_url}/health", timeout=2) + if resp.ok: + return + except (requests.ConnectionError, requests.Timeout): + pass + 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_CONFIGS, + ids=[c.python_version for c in COMPAT_CONFIGS], +) +def compat_config(request: pytest.FixtureRequest) -> CompatConfig: + return request.param + + +@pytest.fixture(scope="module") +def worker_project_dir() -> Path: + raise NotImplementedError( + "override the `worker_project_dir` fixture in your test module" + ) + + +@pytest.fixture(scope="module") +def dev_server( + tmp_path_factory: pytest.TempPathFactory, + worker_project_dir: Path, + compat_config: CompatConfig, +) -> Generator[str]: + tmp_path = tmp_path_factory.mktemp(f"{worker_project_dir.name}_dev") + target = tmp_path / worker_project_dir.name + shutil.copytree( + worker_project_dir, + target, + ignore=shutil.ignore_patterns( + ".venv", ".venv-workers", ".wrangler", "__pycache__", "node_modules" + ), + ) + env = os.environ | {"_PYODIDE_EXTRA_MOUNTS": str(tmp_path)} + + wrangler_jsonc = target / "wrangler.jsonc" + replace_compat_date(wrangler_jsonc, compat_config.compat_date) + inject_compat_flags(wrangler_jsonc, compat_config.extra_compat_flags) + + pywrangler_cmd = [ + "uv", + "run", + "--frozen", + "--no-project", + "--with", + str(WORKERS_PY), + "pywrangler", + ] + + subprocess.run( + [*pywrangler_cmd, "sync"], + cwd=target, + check=True, + env=env, + ) + + shutil.copytree(WORKERS_RUNTIME_SDK, target / "python_modules", dirs_exist_ok=True) + shutil.copytree( + DJANGO_CF_SRC, + target / "python_modules" / "django_cf", + dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__"), + ) + + port = get_free_port() + base_url = f"http://127.0.0.1:{port}" + + log_path = tmp_path / "dev.log" + with log_path.open("w") as log_file: + process = subprocess.Popen( + [ + *pywrangler_cmd, + "dev", + "--port", + str(port), + "--persist-to", + str(tmp_path / "state"), + ], + cwd=target, + stdout=log_file, + stderr=subprocess.STDOUT, + env=env, + ) + + wait_for_ready(process, base_url, log_path) + yield base_url + + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +@functools.cache +def get_suite_results(dev_server: str, suite: str) -> SuiteResults | str: + try: + resp = requests.get( + f"{dev_server}/run-tests/{suite}", + timeout=(SUITE_CONNECT_TIMEOUT, SUITE_READ_TIMEOUT), + ) + except requests.RequestException as error: + return f"Suite '{suite}' request failed: {error}" + if not resp.ok: + return 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_suite_results(dev_server, suite) + if isinstance(results, str): + pytest.fail(results) + return + result: InWorkerTestResult | None = results.get(test_name) + assert result is not None, ( + f"Test {suite}::{test_name} not found in results; " + f"available keys: {sorted(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 make_suite_class(suite: str, tests: list[str]) -> type: + return type( + f"Test{suite.upper()}", + (), + {f"test_{name}": _make_test(suite, name) for name in tests}, + ) + + +def _normalize_test_name(*parts: str) -> str: + normalized = [] + for part in parts: + normalized.append(part[len("test_") :] if part.startswith("test_") else part) + return "__".join(normalized) + + +def discover_test_names(module_path: Path) -> list[str]: + tree = ast.parse(module_path.read_text()) + names = [] + for node in tree.body: + if isinstance( + node, ast.FunctionDef | ast.AsyncFunctionDef + ) and node.name.startswith("test_"): + names.append(_normalize_test_name(node.name)) + elif isinstance(node, ast.ClassDef): + for child in node.body: + if isinstance( + child, ast.FunctionDef | ast.AsyncFunctionDef + ) and child.name.startswith("test_"): + names.append(_normalize_test_name(node.name, child.name)) + return names + + +def discover_suites(src_dir: Path) -> dict[str, list[str]]: + return { + module_path.stem[len("test_") :]: discover_test_names(module_path) + for module_path in sorted(src_dir.glob("test_*.py")) + } + + +def register_in_worker_suites(namespace: dict[str, Any], src_dir: Path) -> None: + for suite, test_names in discover_suites(src_dir).items(): + suite_cls = make_suite_class(suite, test_names) + namespace[suite_cls.__name__] = suite_cls diff --git a/packages/django-cf/tests/middleware/__init__.py b/packages/django-cf/tests/middleware/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/django-cf/tests/middleware/test_cloudflare_access.py b/packages/django-cf/tests/middleware/test_cloudflare_access.py deleted file mode 100644 index 3952fe96..00000000 --- a/packages/django-cf/tests/middleware/test_cloudflare_access.py +++ /dev/null @@ -1,951 +0,0 @@ -"""Tests for CloudflareAccessMiddleware.""" - -import base64 -import json -import time -from unittest.mock import MagicMock, patch - -import pytest - - -# Helper functions to create test JWTs -def base64url_encode(data): - """Encode bytes to base64url string without padding.""" - if isinstance(data, str): - data = data.encode("utf-8") - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") - - -def create_jwt_parts(header, payload): - """Create the header and payload parts of a JWT.""" - header_b64 = base64url_encode(json.dumps(header)) - payload_b64 = base64url_encode(json.dumps(payload)) - return header_b64, payload_b64 - - -def create_test_jwt(payload, kid="test-key-1"): - """Create a test JWT (unsigned, for testing extraction functions).""" - header = {"alg": "RS256", "typ": "JWT", "kid": kid} - header_b64, payload_b64 = create_jwt_parts(header, payload) - # Fake signature for structure testing - signature_b64 = base64url_encode(b"fake-signature") - return f"{header_b64}.{payload_b64}.{signature_b64}" - - -@pytest.fixture -def mock_django_settings(): - """Mock Django settings for middleware.""" - settings = MagicMock() - settings.CLOUDFLARE_ACCESS_AUD = "test-aud-12345" - settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = ["/health/", "/public/"] - settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - return settings - - -@pytest.fixture -def mock_user_model(): - """Mock Django User model.""" - user = MagicMock() - user.email = "test@example.com" - user.first_name = "Test" - user.last_name = "User" - user.get_full_name.return_value = "Test User" - return user - - -@pytest.fixture -def mock_request(): - """Create a mock Django request.""" - request = MagicMock() - request.META = {} - request.COOKIES = {} - request.path = "/api/resource/" - request.session = MagicMock() - return request - - -class TestJWTExtraction: - """Tests for JWT token extraction from request.""" - - def test_extract_jwt_from_header(self, mock_request): - """Test extracting JWT from CF-Access-Jwt-Assertion header.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - test_token = "header-jwt-token" - mock_request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = test_token - - result = middleware._extract_jwt_token(mock_request) - assert result == test_token - - def test_extract_jwt_from_cookie(self, mock_request): - """Test extracting JWT from CF_Authorization cookie.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - test_token = "cookie-jwt-token" - mock_request.COOKIES["CF_Authorization"] = test_token - - result = middleware._extract_jwt_token(mock_request) - assert result == test_token - - def test_extract_jwt_from_lowercase_cookie(self, mock_request): - """Test extracting JWT from cf_authorization cookie (lowercase).""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - test_token = "lowercase-cookie-jwt-token" - mock_request.COOKIES["cf_authorization"] = test_token - - result = middleware._extract_jwt_token(mock_request) - assert result == test_token - - def test_extract_jwt_no_token(self, mock_request): - """Test that None is returned when no JWT is present.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - result = middleware._extract_jwt_token(mock_request) - assert result is None - - def test_no_broken_dash_header_lookup(self, mock_request): - """Test that _extract_jwt_token does not try to look up headers with dashes. - - Previously the code had a fallback that looked up - 'HTTP_CF_ACCESS_JWT_ASSERTION'.replace('_', '-') which evaluated to - 'HTTP-CF-ACCESS-JWT-ASSERTION' - a key that could never exist in - Django's request.META (Django always uses underscores for HTTP headers). - """ - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Verify the source code does NOT contain the broken dash-based lookup - import inspect - - source = inspect.getsource(middleware._extract_jwt_token) - assert ".replace('_', '-')" not in source - assert '.replace("_", "-")' not in source - - # Without header or cookie, should return None (no broken fallback) - mock_request.META = {} - mock_request.COOKIES = {} - result = middleware._extract_jwt_token(mock_request) - assert result is None - - # With correct underscore-based header key, should find it - mock_request.META = {"HTTP_CF_ACCESS_JWT_ASSERTION": "test-token"} - result = middleware._extract_jwt_token(mock_request) - assert result == "test-token" - - def test_header_takes_precedence_over_cookie(self, mock_request): - """Test that header JWT takes precedence over cookie.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - mock_request.META["HTTP_CF_ACCESS_JWT_ASSERTION"] = "header-token" - mock_request.COOKIES["CF_Authorization"] = "cookie-token" - - result = middleware._extract_jwt_token(mock_request) - assert result == "header-token" - - -class TestTeamNameExtraction: - """Tests for team name extraction from JWT.""" - - def test_extract_team_name_from_valid_jwt(self): - """Test extracting team name from JWT issuer claim.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - payload = { - "iss": "https://myteam.cloudflareaccess.com", - "email": "user@example.com", - } - jwt_token = create_test_jwt(payload) - - result = middleware._extract_team_name_from_jwt(jwt_token) - assert result == "myteam" - - def test_extract_team_name_invalid_issuer_format(self): - """Test that None is returned for non-Cloudflare issuer.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - payload = { - "iss": "https://other-issuer.com", - "email": "user@example.com", - } - jwt_token = create_test_jwt(payload) - - result = middleware._extract_team_name_from_jwt(jwt_token) - assert result is None - - def test_extract_team_name_missing_issuer(self): - """Test that None is returned when issuer is missing.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - payload = {"email": "user@example.com"} - jwt_token = create_test_jwt(payload) - - result = middleware._extract_team_name_from_jwt(jwt_token) - assert result is None - - def test_extract_team_name_malformed_jwt(self): - """Test that None is returned for malformed JWT.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # JWT with only 2 parts instead of 3 - result = middleware._extract_team_name_from_jwt("invalid.jwt") - assert result is None - - -class TestExemptPaths: - """Tests for exempt path functionality.""" - - def test_exempt_path_matches(self): - """Test that exempt paths are correctly identified.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = ["/health/", "/public/"] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware._is_exempt_path("/health/") is True - assert middleware._is_exempt_path("/health/check") is True - assert middleware._is_exempt_path("/public/") is True - assert middleware._is_exempt_path("/public/resource") is True - - def test_non_exempt_path(self): - """Test that non-exempt paths are correctly identified.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = ["/health/", "/public/"] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware._is_exempt_path("/api/") is False - assert middleware._is_exempt_path("/admin/") is False - assert middleware._is_exempt_path("/") is False - - def test_empty_exempt_paths(self): - """Test behavior with no exempt paths configured.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware._is_exempt_path("/any/path/") is False - - -class TestMiddlewareInitialization: - """Tests for middleware initialization.""" - - def test_init_with_aud_only(self): - """Test initialization with only AUD setting.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = None - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware.aud == "test-aud" - assert middleware.team_name is None - assert middleware.team_domain is None - assert middleware.certs_url is None - - def test_init_with_team_name_only(self): - """Test initialization with only team name setting.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = None - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware.aud is None - assert middleware.team_name == "testteam" - assert middleware.team_domain == "testteam.cloudflareaccess.com" - assert ( - middleware.certs_url - == "https://testteam.cloudflareaccess.com/cdn-cgi/access/certs" - ) - - def test_init_with_both_settings(self): - """Test initialization with both AUD and team name.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - assert middleware.aud == "test-aud" - assert middleware.team_name == "testteam" - - def test_init_without_required_settings(self): - """Test that ValueError is raised without required settings.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = None - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = None - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - with pytest.raises(ValueError) as exc_info: - CloudflareAccessMiddleware(lambda r: r) - - assert ( - "Either CLOUDFLARE_ACCESS_AUD or CLOUDFLARE_ACCESS_TEAM_NAME" - in str(exc_info.value) - ) - - -class TestBase64UrlDecode: - """Tests for base64url decoding.""" - - def test_base64url_decode_standard(self): - """Test standard base64url decoding.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Test with known value - encoded = base64url_encode(b"hello world") - result = middleware._base64url_decode(encoded) - assert result == b"hello world" - - def test_base64url_decode_with_padding_needed(self): - """Test base64url decoding with padding that needs to be added.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # 'ab' encodes to 'YWI' which needs 1 padding char - result = middleware._base64url_decode("YWI") - assert result == b"ab" - - -class TestJWTDecodeAndVerify: - """Tests for JWT decoding and verification.""" - - def test_decode_jwt_invalid_format(self): - """Test that invalid JWT format returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - key_data = {"kid": "test-key", "n": 123, "e": 65537} - - # JWT with wrong number of parts - result = middleware._decode_and_verify_jwt("only.two", key_data) - assert result is None - - def test_decode_jwt_key_id_mismatch(self): - """Test that key ID mismatch returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Create JWT with one key ID - payload = {"email": "test@example.com"} - jwt_token = create_test_jwt(payload, kid="key-1") - - # Try to verify with different key ID - key_data = {"kid": "key-2", "n": 123, "e": 65537} - - result = middleware._decode_and_verify_jwt(jwt_token, key_data) - assert result is None - - def test_decode_jwt_unsupported_algorithm(self): - """Test that unsupported algorithm returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Create JWT with HS256 algorithm - header = {"alg": "HS256", "typ": "JWT", "kid": "test-key"} - payload = {"email": "test@example.com"} - header_b64, payload_b64 = create_jwt_parts(header, payload) - signature_b64 = base64url_encode(b"fake-signature") - jwt_token = f"{header_b64}.{payload_b64}.{signature_b64}" - - key_data = {"kid": "test-key", "n": 123, "e": 65537} - - result = middleware._decode_and_verify_jwt(jwt_token, key_data) - assert result is None - - def test_decode_jwt_expired_token(self): - """Test that expired token returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Create expired JWT - payload = { - "email": "test@example.com", - "exp": int(time.time()) - 3600, # Expired 1 hour ago - } - jwt_token = create_test_jwt(payload, kid="test-key") - - key_data = {"kid": "test-key", "n": 123, "e": 65537} - - result = middleware._decode_and_verify_jwt(jwt_token, key_data) - assert result is None - - def test_decode_jwt_not_yet_valid(self): - """Test that not-yet-valid token returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Create JWT with nbf in the future - payload = { - "email": "test@example.com", - "nbf": int(time.time()) + 3600, # Valid in 1 hour - } - jwt_token = create_test_jwt(payload, kid="test-key") - - key_data = {"kid": "test-key", "n": 123, "e": 65537} - - result = middleware._decode_and_verify_jwt(jwt_token, key_data) - assert result is None - - -class TestRSAKeyProcessing: - """Tests for RSA key processing from JWK format.""" - - def test_process_rsa_key_valid(self): - """Test processing valid RSA key from JWK.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - # Sample JWK with small values for testing - key_info = { - "kty": "RSA", - "kid": "test-key-1", - "n": base64url_encode( - b"\x00\x01\x00\x01" - ), # Small modulus for testing - "e": base64url_encode(b"\x01\x00\x01"), # Exponent (65537) - } - - result = middleware._process_rsa_key(key_info) - - assert result is not None - assert result["kid"] == "test-key-1" - assert "n" in result - assert "e" in result - - def test_process_rsa_key_missing_n(self): - """Test that missing modulus returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - key_info = { - "kty": "RSA", - "kid": "test-key-1", - "e": base64url_encode(b"\x01\x00\x01"), - # 'n' is missing - } - - result = middleware._process_rsa_key(key_info) - assert result is None - - def test_process_rsa_key_missing_e(self): - """Test that missing exponent returns None.""" - with patch.dict( - "sys.modules", - { - "django.contrib.auth": MagicMock(), - "django.contrib.auth.models": MagicMock(), - "django.http": MagicMock(), - "django.conf": MagicMock(), - "django.core.cache": MagicMock(), - }, - ): - with patch("django.conf.settings") as mock_settings: - mock_settings.CLOUDFLARE_ACCESS_AUD = "test-aud" - mock_settings.CLOUDFLARE_ACCESS_TEAM_NAME = "testteam" - mock_settings.CLOUDFLARE_ACCESS_EXEMPT_PATHS = [] - mock_settings.CLOUDFLARE_ACCESS_CACHE_TIMEOUT = 3600 - - from django_cf.middleware.CloudflareAccessMiddleware import ( - CloudflareAccessMiddleware, - ) - - middleware = CloudflareAccessMiddleware(lambda r: r) - - key_info = { - "kty": "RSA", - "kid": "test-key-1", - "n": base64url_encode(b"\x00\x01\x00\x01"), - # 'e' is missing - } - - result = middleware._process_rsa_key(key_info) - assert result is None diff --git a/packages/django-cf/tests/r2/test_storage_errors.py b/packages/django-cf/tests/r2/test_storage_errors.py deleted file mode 100644 index 84b9e837..00000000 --- a/packages/django-cf/tests/r2/test_storage_errors.py +++ /dev/null @@ -1,668 +0,0 @@ -"""Tests for R2 storage error handling and edge cases.""" - -from io import BytesIO -from unittest.mock import MagicMock, patch - -import pytest - - -class TestR2StorageInitialization: - """Tests for R2Storage initialization.""" - - def test_init_default_values(self): - """Test R2Storage initializes with default values.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - assert storage.binding == "BUCKET" - assert storage.location == "" - assert storage.allow_overwrite is False - assert storage._bucket is None - - def test_init_custom_values(self): - """Test R2Storage initializes with custom values.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage( - binding="MY_BUCKET", location="uploads/", allow_overwrite=True - ) - - assert storage.binding == "MY_BUCKET" - assert storage.location == "uploads" # Trailing slash stripped - assert storage.allow_overwrite is True - - def test_init_strips_slashes_from_location(self): - """Test that leading/trailing slashes are stripped from location.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(location="/uploads/files/") - - assert storage.location == "uploads/files" - - -class TestR2StorageFullPath: - """Tests for R2Storage._full_path method.""" - - def test_full_path_no_location(self): - """Test _full_path without location prefix.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(location="") - result = storage._full_path("test/file.txt") - - assert result == "test/file.txt" - - def test_full_path_with_location(self): - """Test _full_path with location prefix.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(location="uploads") - result = storage._full_path("test/file.txt") - - assert result == "uploads/test/file.txt" - - -class TestR2StorageGetBucketErrors: - """Tests for R2Storage._get_bucket error handling.""" - - def test_get_bucket_raises_on_non_worker(self): - """Test _get_bucket raises exception when not in worker.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - with patch("django_cf.storage.r2.R2Storage._get_bucket") as mock_get: - mock_get.side_effect = Exception("Code not running inside a worker!") - - with pytest.raises(Exception) as exc_info: - storage._get_bucket() - - assert "not running inside a worker" in str(exc_info.value) - - -class TestR2StorageReadErrors: - """Tests for R2Storage._read error handling.""" - - def test_read_returns_none_on_not_found(self): - """Test _read returns None when file not found.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(return_value=None) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - result = storage._read("nonexistent.txt") - - assert result is None - - def test_read_returns_none_on_exception(self): - """Test _read returns None on any exception.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(side_effect=Exception("Network error")) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - # _get_bucket will try to use the mock - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - result = storage._read("file.txt") - - assert result is None - - -class TestR2StorageExistsErrors: - """Tests for R2Storage.exists error handling.""" - - def test_exists_returns_false_on_exception(self): - """Test exists returns False on any exception.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(side_effect=Exception("Permission denied")) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - result = storage.exists("file.txt") - - assert result is False - - -class TestR2StorageSizeErrors: - """Tests for R2Storage.size error handling.""" - - def test_size_returns_zero_on_exception(self): - """Test size returns 0 on any exception.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(side_effect=Exception("Error")) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - result = storage.size("file.txt") - - assert result == 0 - - def test_size_returns_zero_when_no_size_attribute(self): - """Test size returns 0 when metadata has no size attribute.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_metadata = MagicMock(spec=[]) # No 'size' attribute - mock_run_sync = MagicMock() - mock_run_sync.return_value.to_py.return_value = mock_metadata - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - result = storage.size("file.txt") - - assert result == 0 - - -class TestR2StorageUrlErrors: - """Tests for R2Storage.url error handling.""" - - def test_url_raises_without_media_url(self): - """Test url raises ValueError when MEDIA_URL not configured.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - # Create a mock settings object - class MockSettings: - MEDIA_URL = None - - # Patch the import inside the url() method - with patch.dict( - "sys.modules", {"django.conf": MagicMock(settings=MockSettings())} - ): - # Need to reload to pick up the mock, but since the import is local, - # we just need to make sure hasattr works correctly - pass - - # Alternative approach: mock hasattr and settings directly - mock_settings = MagicMock(spec=[]) # Empty spec = no MEDIA_URL attribute - with patch("django.conf.settings", mock_settings): - with pytest.raises(ValueError) as exc_info: - storage.url("file.txt") - - assert "MEDIA_URL must be configured" in str(exc_info.value) - - def test_url_raises_with_empty_media_url(self): - """Test url raises ValueError when MEDIA_URL is empty.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - mock_settings = MagicMock() - mock_settings.MEDIA_URL = "" - - with patch("django.conf.settings", mock_settings): - with pytest.raises(ValueError) as exc_info: - storage.url("file.txt") - - assert "MEDIA_URL must be configured" in str(exc_info.value) - - def test_url_constructs_correct_path(self): - """Test url constructs correct URL with MEDIA_URL.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(location="uploads") - - mock_settings = MagicMock() - mock_settings.MEDIA_URL = "/media/" - - with patch("django.conf.settings", mock_settings): - result = storage.url("file.txt") - - assert result == "/media/uploads/file.txt" - - -class TestR2StorageGetModifiedTimeErrors: - """Tests for R2Storage.get_modified_time error handling.""" - - def test_get_modified_time_returns_now_on_exception(self): - """Test get_modified_time returns current time on exception.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from datetime import datetime - - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock(side_effect=Exception("Error")) - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - before = datetime.now() - result = storage.get_modified_time("file.txt") - after = datetime.now() - - assert before <= result <= after - - -class TestR2StorageGetAvailableName: - """Tests for R2Storage.get_available_name method.""" - - def test_get_available_name_raises_on_too_long(self): - """Test get_available_name raises exception when name too long.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - long_name = "a" * 300 - - with pytest.raises(Exception) as exc_info: - storage.get_available_name(long_name, max_length=100) - - assert "too long" in str(exc_info.value) - - def test_get_available_name_returns_original_when_allow_overwrite(self): - """Test get_available_name returns original name when allow_overwrite.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage(allow_overwrite=True) - - result = storage.get_available_name("file.txt") - - assert result == "file.txt" - - def test_get_available_name_returns_original_when_not_exists(self): - """Test get_available_name returns original when file doesn't exist.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - with patch.object(storage, "exists", return_value=False): - result = storage.get_available_name("file.txt") - - assert result == "file.txt" - - def test_get_available_name_increments_counter(self): - """Test get_available_name increments counter for existing files.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - - # Call sequence: - # 1. exists('file.txt') -> True (line 268, don't return early) - # 2. exists('file.txt') -> True (line 275 while check, enter loop, name becomes file_1.txt) - # 3. exists('file_1.txt') -> False (line 275 while check, exit loop) - with patch.object(storage, "exists", side_effect=[True, True, False]): - result = storage.get_available_name("file.txt") - - assert result == "file_1.txt" - - -class TestR2FileClass: - """Tests for R2File class.""" - - def test_r2file_read_mode(self): - """Test R2File in read mode.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2File - - mock_storage = MagicMock() - mock_storage._read.return_value = b"test content" - - r2file = R2File("test.txt", mock_storage, mode="rb") - - content = r2file.read() - assert content == b"test content" - - def test_r2file_write_raises_in_read_mode(self): - """Test R2File.write raises when in read mode.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2File - - mock_storage = MagicMock() - mock_storage._read.return_value = b"" - - r2file = R2File("test.txt", mock_storage, mode="rb") - - with pytest.raises(AttributeError) as exc_info: - r2file.write(b"new content") - - assert "not opened for writing" in str(exc_info.value) - - def test_r2file_write_allowed_in_write_mode(self): - """Test R2File.write works in write mode.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2File - - mock_storage = MagicMock() - mock_storage._read.return_value = b"" - - r2file = R2File("test.txt", mock_storage, mode="wb") - - # Access file property to initialize BytesIO - _ = r2file.file - - result = r2file.write(b"new content") - assert result == 11 # Length of 'new content' - - def test_r2file_close(self): - """Test R2File.close properly closes internal file.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2File - - mock_storage = MagicMock() - mock_storage._read.return_value = b"content" - - r2file = R2File("test.txt", mock_storage) - - # Access file to initialize it - _ = r2file.file - assert r2file._file is not None - - r2file.close() - # After close, the internal BytesIO should be closed - - -class TestR2StorageSaveEdgeCases: - """Tests for R2Storage._save edge cases.""" - - def test_save_with_file_like_object(self): - """Test _save with file-like object that has read() method.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock() - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - # Create a file-like object - content = BytesIO(b"test content") - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - with patch("django_cf.storage.r2.Uint8Array"): - result = storage._save("test.txt", content) - - assert result == "test.txt" - - def test_save_with_content_type(self): - """Test _save preserves content_type when available.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock() - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - # Create a file-like object with content_type - content = BytesIO(b'{"key": "value"}') - content.content_type = "application/json" - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - with patch("django_cf.storage.r2.Uint8Array"): - storage._save("data.json", content) - - # TODO: assert put was called with the expected httpMetadata options. - - -class TestR2StorageListdirEdgeCases: - """Tests for R2Storage.listdir edge cases.""" - - def test_listdir_with_empty_path(self): - """Test listdir with empty path.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock() - mock_run_sync.return_value.to_py.return_value = { - "objects": [], - "delimitedPrefixes": [], - } - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - dirs, files = storage.listdir("") - - assert dirs == [] - assert files == [] - - def test_listdir_adds_trailing_slash(self): - """Test listdir adds trailing slash to path.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_bucket = MagicMock() - mock_run_sync = MagicMock() - mock_run_sync.return_value.to_py.return_value = { - "objects": [], - "delimitedPrefixes": [], - } - - storage._bucket = mock_bucket - storage._run_sync = mock_run_sync - - with patch.object(storage, "_get_bucket", return_value=mock_bucket): - storage.listdir("uploads") - - # Verify list was called with prefix ending in / - list_call = mock_bucket.list.call_args - assert list_call[0][0]["prefix"].endswith("/") - - -class TestR2StorageTimeMethodsFallback: - """Tests for R2Storage time methods fallback behavior.""" - - def test_get_accessed_time_returns_modified_time(self): - """Test get_accessed_time falls back to get_modified_time.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from datetime import datetime - - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_time = datetime(2023, 1, 15, 12, 0, 0) - - with patch.object(storage, "get_modified_time", return_value=mock_time): - result = storage.get_accessed_time("file.txt") - - assert result == mock_time - - def test_get_created_time_returns_modified_time(self): - """Test get_created_time falls back to get_modified_time.""" - with patch.dict( - "sys.modules", - { - "js": MagicMock(), - }, - ): - from datetime import datetime - - from django_cf.storage.r2 import R2Storage - - storage = R2Storage() - mock_time = datetime(2023, 1, 15, 12, 0, 0) - - with patch.object(storage, "get_modified_time", return_value=mock_time): - result = storage.get_created_time("file.txt") - - assert result == mock_time diff --git a/packages/django-cf/tests/test_wsgi_handler.py b/packages/django-cf/tests/test_wsgi_handler.py deleted file mode 100644 index cb5c36e8..00000000 --- a/packages/django-cf/tests/test_wsgi_handler.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Tests for WSGI handler and DjangoCF classes.""" - -import pytest - -from django_cf import DjangoCF, DjangoCFDurableObject - - -class TestDjangoCFErrorMessages: - """Tests for DjangoCF class error messages.""" - - def test_djangocf_get_app_error_message(self): - """Test that DjangoCF.get_app() raises NotImplementedError with correct message.""" - cf = DjangoCF() - with pytest.raises(NotImplementedError) as exc_info: - cf.get_app() - # Verify no duplicate "implement" word - assert ( - str(exc_info.value) == "Please implement get_app in your django_cf worker" - ) - assert "implement implement" not in str(exc_info.value) - - def test_djangocf_durable_object_get_app_error_message(self): - """Test that DjangoCFDurableObject.get_app() raises NotImplementedError with correct message.""" - # DjangoCFDurableObject.__init__ requires ctx and env, so call the unbound - # get_app rather than instantiating. It never touches self. - with pytest.raises(NotImplementedError) as exc_info: - DjangoCFDurableObject.get_app(None) - # Verify no duplicate "implement" word - assert ( - str(exc_info.value) == "Please implement get_app in your django_cf worker" - ) - assert "implement implement" not in str(exc_info.value) - - -class TestWSGIHeaderTransformation: - """Tests for WSGI header name transformation. - - Per PEP 3333 (WSGI spec), HTTP headers should be stored in the environ dict as - HTTP_HEADER_NAME where dashes are replaced with underscores and everything is uppercased. - - The handle_wsgi function can't be tested directly (requires Pyodide/js imports), - so we verify the transformation logic via source code inspection. - """ - - def test_header_transformation_replaces_dashes(self): - """Verify the header transformation code replaces dashes with underscores.""" - import inspect - - from django_cf import handle_wsgi - - source = inspect.getsource(handle_wsgi) - # The header loop should include .replace("-", "_") for WSGI compliance - assert '.replace("-", "_")' in source or ".replace('-', '_')" in source - - def test_header_transformation_logic_directly(self): - """Test the header transformation expression produces correct WSGI keys.""" - # This tests the exact expression used in handle_wsgi line 42: - # f'HTTP_{header[0].upper().replace("-", "_")}' - test_cases = [ - ("content-type", "HTTP_CONTENT_TYPE"), - ("x-forwarded-for", "HTTP_X_FORWARDED_FOR"), - ("cf-access-jwt-assertion", "HTTP_CF_ACCESS_JWT_ASSERTION"), - ("accept", "HTTP_ACCEPT"), - ("x-custom-header", "HTTP_X_CUSTOM_HEADER"), - ("cf-connecting-ip", "HTTP_CF_CONNECTING_IP"), - ] - for header_name, expected_key in test_cases: - result = f"HTTP_{header_name.upper().replace('-', '_')}" - assert result == expected_key, ( - f"Header '{header_name}' should map to '{expected_key}', got '{result}'" - ) From 618048cd7f7a502bb8265729ba853b346f9d722d Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 11 Aug 2026 22:08:15 +0900 Subject: [PATCH 5/7] test(django-cf): fold the worker harness into one conftest tests/in_worker_harness.py and tests/in_worker/conftest.py were a second copy of what tests/conftest.py already did: two ways to start `pywrangler dev`, two readiness probes, two teardowns. They are now one file. The two start-up modes are not interchangeable and both survive - the app fixtures need uv project mode for their collectstatic build step, the in-worker fixture needs --no-project plus hand-vendored libraries - but they now share port allocation, the readiness poll and the process-group kill that keeps workerd from being orphaned. `dev_server` becomes `in_worker_server` and points at tests/in_worker/worker directly, which retires the `worker_project_dir` indirection. Python 3.12 leaves the in-worker matrix. That runtime (Pyodide 0.26.0a2) has no JSPI, so pyodide.ffi.run_sync is missing and all 15 R2Storage tests skip themselves; the rest duplicates the 3.13 and 3.14 runs. 505 collected tests become 348 and tests/in_worker drops from ~50s to ~33s. This is not the runtime-sdk's reason for excluding 3.12: these suites contain no async tests, so there were no false passes to fix here. The npm setup-* scripts go too. The harness copies django_cf into a tmpdir per run, so nothing has to be staged into templates/ or tests/servers/ first, and AGENTS.md no longer documents a step that does not exist. 346 passed, 2 skipped. Both skips are the strict xfail in tests/in_worker/worker/src/test_db.py, one per remaining config. --- packages/django-cf/AGENTS.md | 19 +- packages/django-cf/package.json | 6 +- packages/django-cf/pyproject.toml | 2 +- packages/django-cf/tests/conftest.py | 333 +++++++++++++++--- .../django-cf/tests/in_worker/conftest.py | 13 - .../tests/in_worker/test_in_worker.py | 33 +- packages/django-cf/tests/in_worker_harness.py | 289 --------------- 7 files changed, 307 insertions(+), 388 deletions(-) delete mode 100644 packages/django-cf/tests/in_worker/conftest.py delete mode 100644 packages/django-cf/tests/in_worker_harness.py diff --git a/packages/django-cf/AGENTS.md b/packages/django-cf/AGENTS.md index a4d82a5b..6516bcfc 100644 --- a/packages/django-cf/AGENTS.md +++ b/packages/django-cf/AGENTS.md @@ -25,15 +25,10 @@ It is published to PyPI as `django-cf` and imported as `django_cf`. ## Testing - Lint everything with `uvx pre-commit run -a` from the repository root. -- The suites split by whether they need a real Worker: - - Host-runnable, no Node required: `tests/db/`, `tests/middleware/`, `tests/test_wsgi_handler.py`. - - Require `wrangler dev`: `tests/d1/`, `tests/durable_objects/`, `tests/r2/`, `tests/e2e/`, `tests/test_date_trunc.py`. -- A bare `pytest` collects everything and fails without a Node toolchain. For the host-runnable subset, run from `packages/django-cf`: - ```bash - uv sync - uv run pytest tests/db tests/middleware tests/test_wsgi_handler.py - ``` -- The Worker-backed suites additionally need `npm run setup-test`, which runs `npm install` and copies `django_cf/` into each fixture's `python_modules/` directory. Adding, moving or renaming library files means re-running it. -- Those suites get their base URL from the `d1_web_server`, `durable_objects_web_server` and `r2_web_server` fixtures in `tests/utils.py`, each of which spawns `npx wrangler dev` on a free port. -- The template and fixture apps expose management endpoints for test setup, such as `/__run_migrations__/` and `/__create_admin__/`, which creates an admin user with username `admin` and password `password`. -- This package has no test job in `.github/workflows/tests.yml` yet, so nothing here runs in CI. +- Every suite needs a Node toolchain, because every suite runs against a real Worker. Run them from `packages/django-cf` with `uv run --frozen pytest tests`; the `django-test` job in `.github/workflows/tests.yml` runs the same command. +- `tests/conftest.py` is the whole harness. It copies the worker project into a tmpdir, runs `pywrangler sync`, overwrites the vendored `django_cf/` with the working tree, then starts `pywrangler dev` on a free port. Nothing is installed into the repository, so there is no setup step to re-run after editing library files. +- Two shapes of suite: + - `tests/d1/`, `tests/durable_objects/`, `tests/r2/` and `tests/test_date_trunc.py` drive a deployed Django app over HTTP, via the session-scoped `d1_web_server`, `durable_objects_web_server` and `r2_web_server` fixtures. Those apps live in `templates/` and `tests/servers/r2/` and expose management endpoints for setup, such as `/__run_migrations__/` and `/__create_admin__/`, which creates an admin user with username `admin` and password `password`. + - `tests/in_worker/` runs pytest *inside* workerd. The real test bodies are `tests/in_worker/worker/src/test_*.py`; `register_in_worker_suites` discovers them by AST and generates one host-side test per in-worker test, so a failure inside the Worker surfaces as an ordinary pytest failure. `pyproject.toml` ignores that `src` directory so the host collector does not try to import Worker-only modules. +- The in-worker suites run once per entry in `COMPAT_CONFIGS`, currently the 3.13 and 3.14 runtimes. See the `tests/in_worker/test_in_worker.py` docstring for why 3.12 is excluded. +- `tests/e2e/` is excluded via `addopts` and does not run. Most of it duplicates the D1, DO and R2 suites; the cases only it covers are concurrent requests, a 1MB R2 upload and a binary-download `xfail`. Re-enable it or delete it rather than leaving it half-alive. diff --git a/packages/django-cf/package.json b/packages/django-cf/package.json index bed00a67..a6d666b6 100644 --- a/packages/django-cf/package.json +++ b/packages/django-cf/package.json @@ -12,11 +12,7 @@ }, "license": "MIT", "scripts": { - "setup-durable-objects": "cd templates/durable-objects && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../django_cf python_modules/django_cf", - "setup-d1": "cd templates/d1 && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../django_cf python_modules/django_cf", - "setup-test-servers": "cd tests/servers/r2 && npm run dependencies && rm -rf python_modules/django_cf && cp -r ../../../django_cf python_modules/django_cf", - "setup-test": "uv sync && npm run setup-durable-objects && npm run setup-d1 && npm run setup-test-servers", - "test": "pytest", + "test": "uv run pytest", "upgrade-templates": "cd templates/durable-objects && uv add django-cf --upgrade && cd ../d1 && uv add django-cf --upgrade", "build": "rm -rf dist/ && python3 -m build", "publish": "python3 -m twine upload dist/*" diff --git a/packages/django-cf/pyproject.toml b/packages/django-cf/pyproject.toml index ade3e2f7..c5b76274 100644 --- a/packages/django-cf/pyproject.toml +++ b/packages/django-cf/pyproject.toml @@ -93,7 +93,7 @@ lint.per-file-ignores."templates/**" = ["F401"] [tool.pytest.ini_options] minversion = "6.0" -# TODO: flaky. Enable or refactor test cases +# TODO: e2e tests are flaky. Enable or refactor test cases addopts = ["-ra", "--ignore=tests/e2e", "--ignore=tests/in_worker/worker/src"] testpaths = [ "tests", diff --git a/packages/django-cf/tests/conftest.py b/packages/django-cf/tests/conftest.py index d32d3c23..969a1965 100644 --- a/packages/django-cf/tests/conftest.py +++ b/packages/django-cf/tests/conftest.py @@ -1,23 +1,39 @@ """Host-side fixtures that serve the test workers with ``pywrangler dev``. -Ported from ``packages/runtime-sdk/tests/conftest.py``. Unlike that copy, the -servers run in uv *project* mode: each worker's ``wrangler.jsonc`` declares a -``build.command`` that shells out to ``python manage.py collectstatic``, which -needs the project's own virtualenv on PATH. ``uv run --no-project`` would leave -Django unimportable and the build would fail before the worker starts. - -TODO: reduce duplicated code with runtime-sdk after refactoring tests +Two kinds of worker are served from here: + +* The deployable apps under ``templates/`` and ``tests/servers/``, which back + the D1, Durable Objects, R2 and date-trunc suites. Each ``*_web_server`` + fixture starts one, applies migrations, creates the admin user and hands out + its base URL. These run in uv *project* mode because their ``wrangler.jsonc`` + declares a ``build.command`` that shells out to + ``python manage.py collectstatic``, which needs the project's own virtualenv + on PATH; ``uv run --no-project`` would leave Django unimportable and the build + would fail before the worker starts. +* ``tests/in_worker/worker``, which runs pytest *inside* workerd and reports + each result over HTTP. ``register_in_worker_suites`` mirrors those results + back into host-side test functions, once per entry in the compat matrix. + +Ported from ``packages/runtime-sdk/tests/conftest.py``. The overlap with that +copy is deliberate: each package keeps a self-contained harness rather than +importing a sibling package's test helpers. """ +# pyright: reportMissingImports=false, reportMissingModuleSource=false + +import ast +import contextlib +import functools import os import shutil import signal import socket import subprocess import time -from collections.abc import Generator -from dataclasses import dataclass +from collections.abc import Callable, Generator +from dataclasses import dataclass, field from pathlib import Path +from typing import Any, Literal, TypedDict import pytest import requests @@ -25,16 +41,20 @@ TEST_DIR: Path = Path(__file__).parent PACKAGE_DIR: Path = TEST_DIR.parent WORKERS_PY: Path = PACKAGE_DIR.parent / "cli" +WORKERS_RUNTIME_SDK: Path = PACKAGE_DIR.parent / "runtime-sdk" / "src" DJANGO_CF_SRC: Path = PACKAGE_DIR / "django_cf" D1_PROJECT: Path = PACKAGE_DIR / "templates" / "d1" DURABLE_OBJECTS_PROJECT: Path = PACKAGE_DIR / "templates" / "durable-objects" R2_PROJECT: Path = TEST_DIR / "servers" / "r2" +IN_WORKER_PROJECT: Path = TEST_DIR / "in_worker" / "worker" DEV_STARTUP_TIMEOUT: int = 240 DEV_POLL_INTERVAL: float = 0.5 SEED_TIMEOUT: int = 180 TEARDOWN_TIMEOUT: int = 10 +SUITE_CONNECT_TIMEOUT: int = 10 +SUITE_READ_TIMEOUT: int = 300 GENERATED = shutil.ignore_patterns( ".venv", @@ -47,6 +67,41 @@ ) +@dataclass(frozen=True) +class CompatConfig: + compat_date: str + python_version: str + extra_compat_flags: list[str] = field(default_factory=list) + + +COMPAT_CONFIGS: list[CompatConfig] = [ + CompatConfig( + compat_date="2025-09-01", + python_version="3.12", + extra_compat_flags=[ + "enable_python_external_sdk", + "python_process_pth_files", + "python_request_headers_preserve_commas", + ], + ), + CompatConfig( + compat_date="2026-01-01", + python_version="3.13", + extra_compat_flags=[ + "enable_python_external_sdk", + "python_process_pth_files", + "python_request_headers_preserve_commas", + ], + ), + CompatConfig( + compat_date="2026-07-01", + python_version="3.14", + # TODO: remove these when 3.14 is stable, and enabled by date + extra_compat_flags=["python_workers_20260610", "experimental"], + ), +] + + @dataclass(frozen=True) class DevServer: base_url: str @@ -72,8 +127,8 @@ def _terminate(process: subprocess.Popen[bytes]) -> None: process.wait() -def _fail(process: subprocess.Popen[bytes], log_path: Path, message: str) -> None: - _terminate(process) +def _fail(log_path: Path, message: str) -> None: + # Callers run inside `_dev_server`, whose finally block stops the worker. pytest.fail( f"{message}\n\n--- pywrangler dev log ---\n{log_path.read_text(errors='replace')}" ) @@ -82,13 +137,16 @@ def _fail(process: subprocess.Popen[bytes], log_path: Path, message: str) -> Non def _wait_for_ready( process: subprocess.Popen[bytes], base_url: str, log_path: Path ) -> None: + """Block until the worker answers. + + Any status counts: a 404 still proves workerd loaded the script and is + routing requests. + """ deadline = time.monotonic() + DEV_STARTUP_TIMEOUT while time.monotonic() < deadline: if process.poll() is not None: _fail( - process, - log_path, - f"pywrangler dev exited early with code {process.returncode}", + log_path, f"pywrangler dev exited early with code {process.returncode}" ) try: requests.get(base_url, timeout=5) @@ -96,30 +154,56 @@ def _wait_for_ready( except requests.RequestException: time.sleep(DEV_POLL_INTERVAL) - _fail( - process, log_path, f"pywrangler dev was not ready within {DEV_STARTUP_TIMEOUT}s" - ) + _fail(log_path, f"pywrangler dev was not ready within {DEV_STARTUP_TIMEOUT}s") + + +@contextlib.contextmanager +def _dev_server( + target: Path, tmp_path: Path, env: dict[str, str], pywrangler: list[str] +) -> Generator[tuple[str, Path]]: + """Run `pywrangler dev` on a free port, yielding its base URL and log path.""" + port = get_free_port() + base_url = f"http://127.0.0.1:{port}" + log_path = tmp_path / f"{target.name}-dev.log" + + with log_path.open("w") as log_file: + process = subprocess.Popen( + [ + *pywrangler, + "dev", + "--port", + str(port), + "--persist-to", + str(tmp_path / "state"), + ], + cwd=target, + stdout=log_file, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + try: + _wait_for_ready(process, base_url, log_path) + yield base_url, log_path + finally: + _terminate(process) -def _seed(base_url: str, process: subprocess.Popen[bytes], log_path: Path) -> None: +def _seed(base_url: str, log_path: Path) -> None: for endpoint in ("__run_migrations__", "__create_admin__"): try: response = requests.get(f"{base_url}/{endpoint}/", timeout=SEED_TIMEOUT) except requests.RequestException as error: - _fail(process, log_path, f"GET /{endpoint}/ failed: {error}") - if response.status_code != 200: - _fail( - process, - log_path, - f"GET /{endpoint}/ returned {response.status_code}: {response.text[:2000]}", - ) - payload = response.json() - if payload.get("status") == "error": - _fail( - process, - log_path, - f"GET /{endpoint}/ reported: {payload.get('message')}", - ) + _fail(log_path, f"GET /{endpoint}/ failed: {error}") + else: + if response.status_code != 200: + _fail( + log_path, + f"GET /{endpoint}/ returned {response.status_code}: {response.text[:2000]}", + ) + payload = response.json() + if payload.get("status") == "error": + _fail(log_path, f"GET /{endpoint}/ reported: {payload.get('message')}") def _serve(project_dir: Path, tmp_path: Path) -> Generator[DevServer]: @@ -150,32 +234,9 @@ def _serve(project_dir: Path, tmp_path: Path) -> Generator[DevServer]: DJANGO_CF_SRC, vendored, ignore=shutil.ignore_patterns("__pycache__") ) - port = get_free_port() - base_url = f"http://127.0.0.1:{port}" - log_path = tmp_path / f"{project_dir.name}-dev.log" - - with log_path.open("w") as log_file: - process = subprocess.Popen( - [ - *pywrangler, - "dev", - "--port", - str(port), - "--persist-to", - str(tmp_path / "state"), - ], - cwd=target, - stdout=log_file, - stderr=subprocess.STDOUT, - env=env, - start_new_session=True, - ) - try: - _wait_for_ready(process, base_url, log_path) - _seed(base_url, process, log_path) - yield DevServer(base_url) - finally: - _terminate(process) + with _dev_server(target, tmp_path, env, pywrangler) as (base_url, log_path): + _seed(base_url, log_path) + yield DevServer(base_url) @pytest.fixture(scope="session") @@ -195,3 +256,159 @@ def durable_objects_web_server( @pytest.fixture(scope="session") def r2_web_server(tmp_path_factory: pytest.TempPathFactory) -> Generator[DevServer]: yield from _serve(R2_PROJECT, tmp_path_factory.mktemp("r2")) + + +def replace_compat_date(file: Path, compat_date: str) -> None: + file.write_text(file.read_text().replace("%COMPAT_DATE", compat_date)) + + +def inject_compat_flags(file: Path, extra_flags: list[str]) -> None: + if not extra_flags: + return + content = file.read_text() + for flag in extra_flags: + content = content.replace('"python_workers"', f'"python_workers", "{flag}"') + file.write_text(content) + + +@pytest.fixture( + scope="module", + params=COMPAT_CONFIGS, + ids=[c.python_version for c in COMPAT_CONFIGS], +) +def compat_config(request: pytest.FixtureRequest) -> CompatConfig: + return request.param + + +@pytest.fixture(scope="module") +def in_worker_server( + tmp_path_factory: pytest.TempPathFactory, compat_config: CompatConfig +) -> Generator[str]: + """Serve ``tests/in_worker/worker``, once per compat config. + + Unlike the app fixtures above, this one runs ``uv run --no-project`` and + vendors the runtime SDK and django-cf working trees by hand: the worker has + no Django project to build, it only needs the two libraries importable. + """ + tmp_path = tmp_path_factory.mktemp("in_worker") + target = tmp_path / IN_WORKER_PROJECT.name + shutil.copytree(IN_WORKER_PROJECT, target, ignore=GENERATED) + + wrangler_jsonc = target / "wrangler.jsonc" + replace_compat_date(wrangler_jsonc, compat_config.compat_date) + inject_compat_flags(wrangler_jsonc, compat_config.extra_compat_flags) + + pywrangler = [ + "uv", + "run", + "--frozen", + "--no-project", + "--with", + str(WORKERS_PY), + "pywrangler", + ] + env = os.environ | {"_PYODIDE_EXTRA_MOUNTS": str(tmp_path)} + + subprocess.run([*pywrangler, "sync"], cwd=target, check=True, env=env) + + shutil.copytree(WORKERS_RUNTIME_SDK, target / "python_modules", dirs_exist_ok=True) + shutil.copytree( + DJANGO_CF_SRC, + target / "python_modules" / "django_cf", + dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__"), + ) + + with _dev_server(target, tmp_path, env, pywrangler) as (base_url, _): + yield base_url + + +class InWorkerTestResult(TypedDict): + status: Literal["passed", "failed", "error", "skipped"] + error: str + traceback: str + reason: str + + +SuiteResults = dict[str, InWorkerTestResult] + + +@functools.cache +def get_suite_results(in_worker_server: str, suite: str) -> SuiteResults | str: + try: + resp = requests.get( + f"{in_worker_server}/run-tests/{suite}", + timeout=(SUITE_CONNECT_TIMEOUT, SUITE_READ_TIMEOUT), + ) + except requests.RequestException as error: + return f"Suite '{suite}' request failed: {error}" + if not resp.ok: + return 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, in_worker_server: str) -> None: + results = get_suite_results(in_worker_server, suite) + if isinstance(results, str): + pytest.fail(results) + return + result: InWorkerTestResult | None = results.get(test_name) + assert result is not None, ( + f"Test {suite}::{test_name} not found in results; " + f"available keys: {sorted(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 make_suite_class(suite: str, tests: list[str]) -> type: + return type( + f"Test{suite.upper()}", + (), + {f"test_{name}": _make_test(suite, name) for name in tests}, + ) + + +def _normalize_test_name(*parts: str) -> str: + normalized = [] + for part in parts: + normalized.append(part[len("test_") :] if part.startswith("test_") else part) + return "__".join(normalized) + + +def discover_test_names(module_path: Path) -> list[str]: + tree = ast.parse(module_path.read_text()) + names = [] + for node in tree.body: + if isinstance( + node, ast.FunctionDef | ast.AsyncFunctionDef + ) and node.name.startswith("test_"): + names.append(_normalize_test_name(node.name)) + elif isinstance(node, ast.ClassDef): + for child in node.body: + if isinstance( + child, ast.FunctionDef | ast.AsyncFunctionDef + ) and child.name.startswith("test_"): + names.append(_normalize_test_name(node.name, child.name)) + return names + + +def discover_suites(src_dir: Path) -> dict[str, list[str]]: + return { + module_path.stem[len("test_") :]: discover_test_names(module_path) + for module_path in sorted(src_dir.glob("test_*.py")) + } + + +def register_in_worker_suites(namespace: dict[str, Any], src_dir: Path) -> None: + for suite, test_names in discover_suites(src_dir).items(): + suite_cls = make_suite_class(suite, test_names) + namespace[suite_cls.__name__] = suite_cls diff --git a/packages/django-cf/tests/in_worker/conftest.py b/packages/django-cf/tests/in_worker/conftest.py deleted file mode 100644 index 39f35bfd..00000000 --- a/packages/django-cf/tests/in_worker/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -from tests.in_worker_harness import ( - CompatConfig, - compat_config, - dev_server, - worker_project_dir, -) - -__all__ = [ - "CompatConfig", - "compat_config", - "dev_server", - "worker_project_dir", -] diff --git a/packages/django-cf/tests/in_worker/test_in_worker.py b/packages/django-cf/tests/in_worker/test_in_worker.py index a9661ea6..fde74dcb 100644 --- a/packages/django-cf/tests/in_worker/test_in_worker.py +++ b/packages/django-cf/tests/in_worker/test_in_worker.py @@ -1,3 +1,11 @@ +"""Mirrors of the pytest suites that run inside workerd, plus WSGI round-trips. + +Python 3.12 (Pyodide 0.26.0a2) is excluded from the matrix. That runtime has no +JSPI, so ``pyodide.ffi.run_sync`` is missing and every R2Storage test skips +itself; what remains duplicates the 3.13 and 3.14 runs at a third of the +suite's wall clock. +""" + # pyright: reportMissingImports=false, reportMissingModuleSource=false from pathlib import Path @@ -5,23 +13,28 @@ import pytest import requests -from tests.in_worker_harness import register_in_worker_suites +from tests.conftest import COMPAT_CONFIGS, CompatConfig, register_in_worker_suites + +IN_WORKER_SRC_DIR: Path = Path(__file__).parent / "worker" / "src" -IN_WORKER_DIR: Path = Path(__file__).parent / "worker" -IN_WORKER_SRC_DIR: Path = IN_WORKER_DIR / "src" +MATRIX: list[CompatConfig] = [c for c in COMPAT_CONFIGS if c.python_version != "3.12"] -@pytest.fixture(scope="module") -def worker_project_dir() -> Path: - return IN_WORKER_DIR +@pytest.fixture( + scope="module", + params=MATRIX, + ids=[c.python_version for c in MATRIX], +) +def compat_config(request: pytest.FixtureRequest) -> CompatConfig: + return request.param register_in_worker_suites(globals(), IN_WORKER_SRC_DIR) -def test_wsgi_header_transformation(dev_server: str) -> None: +def test_wsgi_header_transformation(in_worker_server: str) -> None: response = requests.get( - f"{dev_server}/wsgi/headers", + f"{in_worker_server}/wsgi/headers", headers={ "cf-access-jwt-assertion": "jwt-token", "x-custom-header": "custom-value", @@ -37,9 +50,9 @@ def test_wsgi_header_transformation(dev_server: str) -> None: assert payload["content_type"] == "text/plain" -def test_wsgi_reads_request_body(dev_server: str) -> None: +def test_wsgi_reads_request_body(in_worker_server: str) -> None: response = requests.post( - f"{dev_server}/wsgi/body", + f"{in_worker_server}/wsgi/body", headers={"content-type": "text/plain"}, data=b"request-body", timeout=10, diff --git a/packages/django-cf/tests/in_worker_harness.py b/packages/django-cf/tests/in_worker_harness.py deleted file mode 100644 index b5d191e3..00000000 --- a/packages/django-cf/tests/in_worker_harness.py +++ /dev/null @@ -1,289 +0,0 @@ -"""Helpers for django-cf suites that execute pytest inside workerd.""" - -# pyright: reportMissingImports=false, reportMissingModuleSource=false - -import ast -import functools -import os -import shutil -import socket -import subprocess -import time -from collections.abc import Callable, Generator -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Literal, TypedDict - -import pytest -import requests - -TEST_DIR: Path = Path(__file__).parent -PACKAGE_DIR: Path = TEST_DIR.parent -WORKERS_PY: Path = PACKAGE_DIR.parent / "cli" -WORKERS_RUNTIME_SDK: Path = PACKAGE_DIR.parent / "runtime-sdk" / "src" -DJANGO_CF_SRC: Path = PACKAGE_DIR / "django_cf" - -DEV_STARTUP_TIMEOUT: int = 120 -DEV_POLL_INTERVAL: float = 0.5 -SUITE_CONNECT_TIMEOUT: int = 10 -SUITE_READ_TIMEOUT: int = 300 - - -@dataclass(frozen=True) -class CompatConfig: - compat_date: str - python_version: str - extra_compat_flags: list[str] = field(default_factory=list) - - -COMPAT_CONFIGS: list[CompatConfig] = [ - CompatConfig( - compat_date="2025-09-01", - python_version="3.12", - extra_compat_flags=[ - "enable_python_external_sdk", - "python_process_pth_files", - "python_request_headers_preserve_commas", - ], - ), - CompatConfig( - compat_date="2026-01-01", - python_version="3.13", - extra_compat_flags=[ - "enable_python_external_sdk", - "python_process_pth_files", - "python_request_headers_preserve_commas", - ], - ), - CompatConfig( - compat_date="2026-07-01", - python_version="3.14", - extra_compat_flags=["python_workers_20260610", "experimental"], - ), -] - - -def replace_compat_date(file: Path, compat_date: str) -> None: - file.write_text(file.read_text().replace("%COMPAT_DATE", compat_date)) - - -def inject_compat_flags(file: Path, extra_flags: list[str]) -> None: - if not extra_flags: - return - content = file.read_text() - for flag in extra_flags: - content = content.replace('"python_workers"', f'"python_workers", "{flag}"') - file.write_text(content) - - -class InWorkerTestResult(TypedDict): - status: Literal["passed", "failed", "error", "skipped"] - error: str - traceback: str - reason: str - - -SuiteResults = dict[str, InWorkerTestResult] - - -def get_free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - -def wait_for_ready( - process: subprocess.Popen[bytes], base_url: str, log_path: Path -) -> None: - deadline = time.time() + DEV_STARTUP_TIMEOUT - while time.time() < deadline: - if process.poll() is not None: - pytest.fail( - f"pywrangler dev exited early with code {process.returncode}\n" - f"stdout: {log_path.read_text(errors='replace')}" - ) - try: - resp = requests.get(f"{base_url}/health", timeout=2) - if resp.ok: - return - except (requests.ConnectionError, requests.Timeout): - pass - 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_CONFIGS, - ids=[c.python_version for c in COMPAT_CONFIGS], -) -def compat_config(request: pytest.FixtureRequest) -> CompatConfig: - return request.param - - -@pytest.fixture(scope="module") -def worker_project_dir() -> Path: - raise NotImplementedError( - "override the `worker_project_dir` fixture in your test module" - ) - - -@pytest.fixture(scope="module") -def dev_server( - tmp_path_factory: pytest.TempPathFactory, - worker_project_dir: Path, - compat_config: CompatConfig, -) -> Generator[str]: - tmp_path = tmp_path_factory.mktemp(f"{worker_project_dir.name}_dev") - target = tmp_path / worker_project_dir.name - shutil.copytree( - worker_project_dir, - target, - ignore=shutil.ignore_patterns( - ".venv", ".venv-workers", ".wrangler", "__pycache__", "node_modules" - ), - ) - env = os.environ | {"_PYODIDE_EXTRA_MOUNTS": str(tmp_path)} - - wrangler_jsonc = target / "wrangler.jsonc" - replace_compat_date(wrangler_jsonc, compat_config.compat_date) - inject_compat_flags(wrangler_jsonc, compat_config.extra_compat_flags) - - pywrangler_cmd = [ - "uv", - "run", - "--frozen", - "--no-project", - "--with", - str(WORKERS_PY), - "pywrangler", - ] - - subprocess.run( - [*pywrangler_cmd, "sync"], - cwd=target, - check=True, - env=env, - ) - - shutil.copytree(WORKERS_RUNTIME_SDK, target / "python_modules", dirs_exist_ok=True) - shutil.copytree( - DJANGO_CF_SRC, - target / "python_modules" / "django_cf", - dirs_exist_ok=True, - ignore=shutil.ignore_patterns("__pycache__"), - ) - - port = get_free_port() - base_url = f"http://127.0.0.1:{port}" - - log_path = tmp_path / "dev.log" - with log_path.open("w") as log_file: - process = subprocess.Popen( - [ - *pywrangler_cmd, - "dev", - "--port", - str(port), - "--persist-to", - str(tmp_path / "state"), - ], - cwd=target, - stdout=log_file, - stderr=subprocess.STDOUT, - env=env, - ) - - wait_for_ready(process, base_url, log_path) - yield base_url - - process.terminate() - try: - process.wait(timeout=10) - except subprocess.TimeoutExpired: - process.kill() - process.wait() - - -@functools.cache -def get_suite_results(dev_server: str, suite: str) -> SuiteResults | str: - try: - resp = requests.get( - f"{dev_server}/run-tests/{suite}", - timeout=(SUITE_CONNECT_TIMEOUT, SUITE_READ_TIMEOUT), - ) - except requests.RequestException as error: - return f"Suite '{suite}' request failed: {error}" - if not resp.ok: - return 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_suite_results(dev_server, suite) - if isinstance(results, str): - pytest.fail(results) - return - result: InWorkerTestResult | None = results.get(test_name) - assert result is not None, ( - f"Test {suite}::{test_name} not found in results; " - f"available keys: {sorted(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 make_suite_class(suite: str, tests: list[str]) -> type: - return type( - f"Test{suite.upper()}", - (), - {f"test_{name}": _make_test(suite, name) for name in tests}, - ) - - -def _normalize_test_name(*parts: str) -> str: - normalized = [] - for part in parts: - normalized.append(part[len("test_") :] if part.startswith("test_") else part) - return "__".join(normalized) - - -def discover_test_names(module_path: Path) -> list[str]: - tree = ast.parse(module_path.read_text()) - names = [] - for node in tree.body: - if isinstance( - node, ast.FunctionDef | ast.AsyncFunctionDef - ) and node.name.startswith("test_"): - names.append(_normalize_test_name(node.name)) - elif isinstance(node, ast.ClassDef): - for child in node.body: - if isinstance( - child, ast.FunctionDef | ast.AsyncFunctionDef - ) and child.name.startswith("test_"): - names.append(_normalize_test_name(node.name, child.name)) - return names - - -def discover_suites(src_dir: Path) -> dict[str, list[str]]: - return { - module_path.stem[len("test_") :]: discover_test_names(module_path) - for module_path in sorted(src_dir.glob("test_*.py")) - } - - -def register_in_worker_suites(namespace: dict[str, Any], src_dir: Path) -> None: - for suite, test_names in discover_suites(src_dir).items(): - suite_cls = make_suite_class(suite, test_names) - namespace[suite_cls.__name__] = suite_cls From 2af2a5aaf6481520573c8b54b6b06b43c48ec7b1 Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Tue, 11 Aug 2026 22:46:47 +0900 Subject: [PATCH 6/7] test(django-cf): restore the original filenames of the migrated suites The in-worker migration merged six host test modules into two, and renamed a lot of what it moved, which made it impossible to check the port against main one test at a time. Split them back to the names main used: test_db.py -> test_base_engine.py, test_d1_backend.py, test_do_backend.py test_middleware_wsgi.py -> test_cloudflare_access.py, test_storage_errors.py, test_wsgi_handler.py Bodies are moved verbatim; only files, class names and test names change. Each file carries its own helpers rather than importing a shared module, matching how the originals looked. Filenames are the suite names the in-worker runner dispatches on, so this also splits the run into six /run-tests/ calls per config instead of two, and generated host-side classes are now CamelCase (TestBaseEngine, not TestBASE_ENGINE). 159 tests on main, 156 now. test_base_engine.py matches main exactly, 68 for 68. The rest: - 4 source-inspection tests are gone, and should be. They asserted on the text of the implementation - that run_query uses `except Exception`, that Error.stackTraceLimit is set, that storage.py contains an import. None survive contact with a real runtime and none tested behaviour. - 4 R2 tests that mocked a raising bucket are gone, replaced by real-binding equivalents against real missing objects: *_on_exception became *_on_not_found. Same intent, different mechanism, so the names changed. - 2 host-only tests are gone: get_bucket_raises_on_non_worker cannot fail that way inside a worker, and the two WSGI header-transformation tests inspected handle_wsgi internals the Worker boundary tests now cover end to end. - 3 genuinely uncovered: size when metadata has no size attribute, save preserving content_type, and the mock-only read-raises path. - 7 added: real D1 read/write round trips, DO storage wiring, and Access user provisioning, none of which had host-side counterparts. Full per-test mapping in /tmp/mig/report_db.md and /tmp/mig/report_middleware.md (not committed). 348 passed, 2 skipped. --- packages/django-cf/tests/conftest.py | 3 +- .../src/{test_db.py => test_base_engine.py} | 424 +----------------- ...ware_wsgi.py => test_cloudflare_access.py} | 253 +---------- .../in_worker/worker/src/test_d1_backend.py | 250 +++++++++++ .../in_worker/worker/src/test_do_backend.py | 200 +++++++++ .../worker/src/test_storage_errors.py | 247 ++++++++++ .../in_worker/worker/src/test_wsgi_handler.py | 30 ++ 7 files changed, 731 insertions(+), 676 deletions(-) rename packages/django-cf/tests/in_worker/worker/src/{test_db.py => test_base_engine.py} (59%) rename packages/django-cf/tests/in_worker/worker/src/{test_middleware_wsgi.py => test_cloudflare_access.py} (59%) create mode 100644 packages/django-cf/tests/in_worker/worker/src/test_d1_backend.py create mode 100644 packages/django-cf/tests/in_worker/worker/src/test_do_backend.py create mode 100644 packages/django-cf/tests/in_worker/worker/src/test_storage_errors.py create mode 100644 packages/django-cf/tests/in_worker/worker/src/test_wsgi_handler.py diff --git a/packages/django-cf/tests/conftest.py b/packages/django-cf/tests/conftest.py index 969a1965..3bca1ae4 100644 --- a/packages/django-cf/tests/conftest.py +++ b/packages/django-cf/tests/conftest.py @@ -370,8 +370,9 @@ def test_fn(self: Any, in_worker_server: str) -> None: def make_suite_class(suite: str, tests: list[str]) -> type: + camel = "".join(part.title() for part in suite.split("_")) return type( - f"Test{suite.upper()}", + f"Test{camel}", (), {f"test_{name}": _make_test(suite, name) for name in tests}, ) diff --git a/packages/django-cf/tests/in_worker/worker/src/test_db.py b/packages/django-cf/tests/in_worker/worker/src/test_base_engine.py similarity index 59% rename from packages/django-cf/tests/in_worker/worker/src/test_db.py rename to packages/django-cf/tests/in_worker/worker/src/test_base_engine.py index 8bb17d13..f6793e83 100644 --- a/packages/django-cf/tests/in_worker/worker/src/test_db.py +++ b/packages/django-cf/tests/in_worker/worker/src/test_base_engine.py @@ -1,102 +1,11 @@ -"""Database backend tests executed inside workerd.""" +"""Base engine tests executed inside workerd.""" # pyright: reportMissingImports=false from decimal import Decimal -from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest -import workers - - -def make_d1_wrapper(defer_foreign_keys=False): - from django_cf.db.backends.d1.base import DatabaseWrapper - - wrapper = DatabaseWrapper.__new__(DatabaseWrapper) - cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) - wrapper.cursor = lambda: cursor_state - wrapper.binding = "DB" - wrapper.run_sync = lambda value: value - return wrapper - - -def make_do_wrapper(defer_foreign_keys=False): - from django_cf.db.backends.do.base import DatabaseWrapper - - wrapper = DatabaseWrapper.__new__(DatabaseWrapper) - cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) - wrapper.cursor = lambda: cursor_state - return wrapper - - -class FakeD1Statement: - def __init__( - self, *, raw_result=None, all_result=None, raw_error=None, all_error=None - ): - self.raw_result = raw_result or [] - self.all_result = all_result or {"results": [], "meta": {}} - self.raw_error = raw_error - self.all_error = all_error - self.bound_params = None - - def bind(self, *params): - self.bound_params = params - return self - - def raw(self): - if self.raw_error is not None: - raise self.raw_error - return self.raw_result - - def all(self): - if self.all_error is not None: - raise self.all_error - return self.all_result - - -class FakeD1Binding: - def __init__(self, statement): - self.statement = statement - self.prepared_queries = [] - - def prepare(self, query): - self.prepared_queries.append(query) - return self.statement - - -class FakeDOArray: - def __init__(self, data): - self.data = data - - def toArray(self): - return self - - def to_py(self): - return self.data - - -class FakeDOStatement: - def __init__(self, data, *, rows_read=0, rows_written=0): - self.data = data - self.rowsRead = rows_read - self.rowsWritten = rows_written - - def raw(self): - return FakeDOArray(self.data) - - -class FakeDOStorage: - def __init__(self, statement=None, *, error=None): - self.statement = statement - self.error = error - self.calls = [] - - def exec(self, query, *params): - if self.error is not None: - raise self.error - self.calls.append((query, params)) - return self.statement class TestCFResult: @@ -694,334 +603,3 @@ def test_replace_date_trunc_functions_unknown_kind(self): ) assert "django_date_trunc('unknown', created_at)" in result - - -class TestD1DatabaseWrapperProcessQuery: - def test_process_query_no_params(self): - wrapper = make_d1_wrapper() - - result_query, result_params = wrapper.process_query( - "SELECT * FROM users WHERE id = %s", None - ) - - assert result_query == "SELECT * FROM users WHERE id = ?" - assert result_params is None - - def test_process_query_with_params(self): - wrapper = make_d1_wrapper() - - result_query, result_params = wrapper.process_query( - "SELECT * FROM users WHERE id = %s AND name = %s", [1, "test"] - ) - - assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" - assert result_params == [1, "test"] - - def test_process_query_null_param_replaced_with_literal(self): - wrapper = make_d1_wrapper() - - result_query, result_params = wrapper.process_query( - "INSERT INTO users (name, email) VALUES (%s, %s)", ["test", None] - ) - - assert "null" in result_query - assert result_params == ["test"] - - def test_process_query_all_null_params(self): - wrapper = make_d1_wrapper() - - result_query, result_params = wrapper.process_query( - "INSERT INTO users (name, email) VALUES (%s, %s)", [None, None] - ) - - assert result_query == "INSERT INTO users (name, email) VALUES (null, null)" - assert result_params == [] - - def test_process_query_mixed_params(self): - wrapper = make_d1_wrapper() - - result_query, result_params = wrapper.process_query( - "UPDATE users SET name = %s, email = %s, age = %s WHERE id = %s", - ["test", None, 25, None], - ) - - assert result_params == ["test", 25] - assert result_query.count("?") == 2 - assert result_query.count("null") == 2 - - def test_process_query_with_defer_foreign_keys(self): - result = make_d1_wrapper(True).process_query( - "INSERT INTO users (name) VALUES (%s)", ["test"] - ) - - assert "PRAGMA defer_foreign_keys = on" in result - assert "PRAGMA defer_foreign_keys = off" in result - - def test_process_query_date_trunc_replacement(self): - wrapper = make_d1_wrapper() - - result_query, _ = wrapper.process_query( - "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", - ["year", "UTC", "UTC"], - ) - - assert "django_date_trunc" not in result_query - assert "CASE" in result_query or "STRFTIME" in result_query - - -class TestD1DatabaseWrapperConfiguration: - def test_vendor_and_display_name(self): - from django_cf.db.backends.d1.base import DatabaseWrapper - - assert DatabaseWrapper.vendor == "cloudflare_d1" - assert DatabaseWrapper.display_name == "D1" - - -class TestD1GetConnectionParams: - def test_missing_binding_raises_error(self): - from django.core.exceptions import ImproperlyConfigured - - wrapper = make_d1_wrapper() - wrapper.settings_dict = {"CLOUDFLARE_BINDING": None} - - with pytest.raises(ImproperlyConfigured) as exc_info: - wrapper.get_connection_params() - - assert "CLOUDFLARE_BINDING" in str(exc_info.value) - - def test_valid_binding_returns_params(self): - wrapper = make_d1_wrapper() - wrapper.settings_dict = {"CLOUDFLARE_BINDING": "MY_DB"} - - assert wrapper.get_connection_params() == {"binding": "MY_DB"} - - -class TestD1ExceptionHandling: - @pytest.mark.xfail( - reason=( - "The `except Exception: raise Error(Error.new().stack)` handler in `run_query` swallows this and re-raises a JS Error. Removing that handler is a separate change; these pass once it lands." - ), - strict=True, - ) - def test_run_query_lets_binding_errors_propagate(self, monkeypatch): - wrapper = make_d1_wrapper() - error = RuntimeError("binding boom") - monkeypatch.setattr( - workers, - "env", - SimpleNamespace(DB=FakeD1Binding(FakeD1Statement(raw_error=error))), - ) - - with pytest.raises(RuntimeError, match="binding boom"): - wrapper.run_query("SELECT * FROM test") - - -class TestD1RunQuery: - def test_read_query_returns_rows(self, monkeypatch): - wrapper = make_d1_wrapper() - statement = FakeD1Statement(raw_result=[[1, "hello"], [2, "world"]]) - binding = FakeD1Binding(statement) - monkeypatch.setattr(workers, "env", SimpleNamespace(DB=binding)) - - result = wrapper.run_query("SELECT * FROM test WHERE id = %s", [1]) - - assert list(result) == [(1, "hello"), (2, "world")] - assert statement.bound_params == (1,) - assert binding.prepared_queries == ["SELECT * FROM test WHERE id = ?"] - - def test_write_query_returns_meta(self, monkeypatch): - wrapper = make_d1_wrapper() - statement = FakeD1Statement( - all_result={ - "results": [["ok"]], - "meta": {"rows_read": 1, "rows_written": 2, "last_row_id": 9}, - } - ) - monkeypatch.setattr( - workers, "env", SimpleNamespace(DB=FakeD1Binding(statement)) - ) - - result = wrapper.run_query("INSERT INTO test VALUES (%s)", ["x"]) - - assert list(result) == [("ok",)] - assert result.rowcount == 2 - assert result.lastrowid == 9 - - -class TestD1ParameterHandling: - def test_empty_params_list(self): - wrapper = make_d1_wrapper() - - result_query, result_params = wrapper.process_query("SELECT * FROM users", []) - - assert result_query == "SELECT * FROM users" - assert result_params == [] - - def test_special_characters_in_params(self): - wrapper = make_d1_wrapper() - - result_query, result_params = wrapper.process_query( - "INSERT INTO users (name) VALUES (%s)", ["test'; DROP TABLE users; --"] - ) - - assert result_params == ["test'; DROP TABLE users; --"] - assert "?" in result_query - - def test_unicode_params(self): - wrapper = make_d1_wrapper() - - _, result_params = wrapper.process_query( - "INSERT INTO users (name) VALUES (%s)", [""] - ) - - assert result_params == [""] - - def test_large_number_of_params(self): - wrapper = make_d1_wrapper() - - placeholders = ", ".join(["%s"] * 50) - result_query, result_params = wrapper.process_query( - f"INSERT INTO test VALUES ({placeholders})", list(range(50)) - ) - - assert result_query.count("?") == 50 - assert len(result_params) == 50 - - -class TestDODatabaseWrapperProcessQuery: - def test_process_query_no_params(self): - wrapper = make_do_wrapper() - - result_query, result_params = wrapper.process_query( - "SELECT * FROM users WHERE id = %s", None - ) - - assert result_query == "SELECT * FROM users WHERE id = ?" - assert result_params is None - - def test_process_query_with_params(self): - wrapper = make_do_wrapper() - - result_query, result_params = wrapper.process_query( - "SELECT * FROM users WHERE id = %s AND name = %s", [1, "test"] - ) - - assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" - assert result_params == [1, "test"] - - def test_process_query_null_param_replaced_with_literal(self): - wrapper = make_do_wrapper() - - result_query, result_params = wrapper.process_query( - "INSERT INTO users (name, email) VALUES (%s, %s)", ["test", None] - ) - - assert "null" in result_query - assert result_params == ["test"] - - def test_process_query_with_defer_foreign_keys(self): - result = make_do_wrapper(True).process_query( - "INSERT INTO users (name) VALUES (%s)", ["test"] - ) - - assert "PRAGMA defer_foreign_keys = on" in result - assert "PRAGMA defer_foreign_keys = off" in result - - -class TestDODatabaseWrapperConfiguration: - def test_vendor_and_display_name(self): - from django_cf.db.backends.do.base import DatabaseWrapper - - assert DatabaseWrapper.vendor == "cloudflare_durable_objects" - assert DatabaseWrapper.display_name == "DO" - - def test_get_connection_params_returns_empty(self): - assert make_do_wrapper().get_connection_params() == {} - - -class TestDOMissingDateTruncBug: - def test_do_process_query_missing_date_trunc(self): - d1_result, _ = make_d1_wrapper().process_query( - "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", - ["year", "UTC", "UTC"], - ) - do_result, _ = make_do_wrapper().process_query( - "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", - ["year", "UTC", "UTC"], - ) - - assert "django_date_trunc" not in d1_result - assert "django_date_trunc" in do_result - - -class TestDOStorageInitialization: - def test_storage_module_exists(self): - from django_cf.db.backends.do import storage - - storage.set_storage(None) - assert storage.get_storage() is None - - def test_run_query_uses_configured_storage(self): - from django_cf.db.backends.do import storage - - fake_storage = FakeDOStorage(FakeDOStatement([[1, "ok"]], rows_read=1)) - storage.set_storage(fake_storage) - - result = make_do_wrapper().run_query("SELECT * FROM test WHERE id = %s", [1]) - - assert list(result) == [(1, "ok")] - assert fake_storage.calls == [("SELECT * FROM test WHERE id = ?", (1,))] - - -class TestDOQueryExecution: - def test_read_query_uses_raw(self): - from django_cf.db.backends.do import storage - - fake_storage = FakeDOStorage(FakeDOStatement([], rows_read=0, rows_written=0)) - storage.set_storage(fake_storage) - - result = make_do_wrapper().run_query("SELECT * FROM users") - - assert result.fetchall() == [] - - -class TestDOExceptionHandling: - def test_run_query_lets_binding_errors_propagate(self): - from django_cf.db.backends.do import storage - - storage.set_storage(FakeDOStorage(error=RuntimeError("storage boom"))) - - with pytest.raises(RuntimeError, match="storage boom"): - make_do_wrapper().run_query("SELECT * FROM test") - - -class TestDOParamConversion: - def test_boolean_true_not_converted_in_process_query(self): - wrapper = make_do_wrapper() - - result_query, result_params = wrapper.process_query( - "INSERT INTO test (active) VALUES (%s)", [True] - ) - - assert result_query == "INSERT INTO test (active) VALUES (?)" - assert result_params == [True] - - def test_multiple_none_params_order_preserved(self): - wrapper = make_do_wrapper() - - result_query, result_params = wrapper.process_query( - "INSERT INTO test (a, b, c, d) VALUES (%s, %s, %s, %s)", - [1, None, 2, None], - ) - - assert result_params == [1, 2] - assert result_query == "INSERT INTO test (a, b, c, d) VALUES (?, null, ?, null)" - - -class TestDOPragmaReturnTypeBug: - def test_pragma_returns_string_not_tuple(self): - result = make_do_wrapper(True).process_query( - "INSERT INTO users (name) VALUES (%s)", ["test"] - ) - - assert isinstance(result, str) diff --git a/packages/django-cf/tests/in_worker/worker/src/test_middleware_wsgi.py b/packages/django-cf/tests/in_worker/worker/src/test_cloudflare_access.py similarity index 59% rename from packages/django-cf/tests/in_worker/worker/src/test_middleware_wsgi.py rename to packages/django-cf/tests/in_worker/worker/src/test_cloudflare_access.py index 69d4de7a..b697f128 100644 --- a/packages/django-cf/tests/in_worker/worker/src/test_middleware_wsgi.py +++ b/packages/django-cf/tests/in_worker/worker/src/test_cloudflare_access.py @@ -1,4 +1,4 @@ -"""Middleware and WSGI tests executed inside workerd.""" +"""Cloudflare Access tests executed inside workerd.""" # pyright: reportMissingImports=false @@ -6,15 +6,10 @@ import importlib import json import time -from datetime import datetime -from io import BytesIO from types import SimpleNamespace from unittest.mock import MagicMock -from uuid import uuid4 -import django.conf import pytest -import workers def base64url_encode(data): @@ -66,34 +61,6 @@ def make_middleware( return middleware_module().CloudflareAccessMiddleware(lambda request: request) -def unique_name(prefix: str) -> str: - return f"{prefix}-{uuid4().hex}" - - -def make_r2_storage(**kwargs): - from django_cf.storage.r2 import R2Storage - - return R2Storage(**kwargs) - - -def make_live_r2_storage(**kwargs): - pyodide_run_sync = None - try: - from pyodide.ffi import run_sync as pyodide_run_sync - except ImportError: - pytest.skip("R2Storage requires pyodide.ffi.run_sync (JSPI)") - assert pyodide_run_sync is not None - - storage = make_r2_storage(**kwargs) - storage._bucket = workers.env.BUCKET - storage._run_sync = pyodide_run_sync - return storage - - -def save_bytes(storage, name: str, data: bytes) -> str: - return storage._save(name, BytesIO(data)) - - class TestJWTExtraction: def test_extract_jwt_from_header(self, monkeypatch): middleware = make_middleware(monkeypatch) @@ -410,221 +377,3 @@ def get(self, email): assert existing.first_name == "New" assert existing.last_name == "Name" existing.save.assert_called_once_with() - - -class TestDjangoCFErrorMessages: - def test_djangocf_get_app_error_message(self): - from django_cf import DjangoCF - - cf = DjangoCF() - with pytest.raises(NotImplementedError) as exc_info: - cf.get_app() - - assert ( - str(exc_info.value) == "Please implement get_app in your django_cf worker" - ) - assert "implement implement" not in str(exc_info.value) - - -class TestR2StorageInitialization: - def test_init_default_values(self): - storage = make_r2_storage() - - assert storage.binding == "BUCKET" - assert storage.location == "" - assert storage.allow_overwrite is False - assert storage._bucket is None - - def test_init_custom_values(self): - storage = make_r2_storage( - binding="MY_BUCKET", location="uploads/", allow_overwrite=True - ) - - assert storage.binding == "MY_BUCKET" - assert storage.location == "uploads" - assert storage.allow_overwrite is True - - def test_init_strips_slashes_from_location(self): - assert make_r2_storage(location="/uploads/files/").location == "uploads/files" - - -class TestR2StoragePathsAndUrls: - def test_full_path_no_location(self): - assert ( - make_r2_storage(location="")._full_path("test/file.txt") == "test/file.txt" - ) - - def test_full_path_with_location(self): - assert ( - make_r2_storage(location="uploads")._full_path("test/file.txt") - == "uploads/test/file.txt" - ) - - def test_url_raises_without_media_url(self, monkeypatch): - storage = make_r2_storage() - - monkeypatch.setattr(django.conf, "settings", SimpleNamespace()) - - with pytest.raises(ValueError, match="MEDIA_URL must be configured"): - storage.url("file.txt") - - def test_url_raises_with_empty_media_url(self, monkeypatch): - storage = make_r2_storage() - - monkeypatch.setattr(django.conf, "settings", SimpleNamespace(MEDIA_URL="")) - - with pytest.raises(ValueError, match="MEDIA_URL must be configured"): - storage.url("file.txt") - - def test_url_constructs_correct_path(self, monkeypatch): - storage = make_r2_storage(location="uploads") - monkeypatch.setattr( - django.conf, "settings", SimpleNamespace(MEDIA_URL="/media/") - ) - - assert storage.url("file.txt") == "/media/uploads/file.txt" - - -class TestR2StorageMissingObjects: - def test_read_returns_none_on_not_found(self): - storage = make_live_r2_storage(location=unique_name("r2-missing-read")) - - assert storage._read("nonexistent.txt") is None - - def test_exists_returns_false_on_not_found(self): - storage = make_live_r2_storage(location=unique_name("r2-missing-exists")) - - assert storage.exists("nonexistent.txt") is False - - def test_size_returns_zero_on_not_found(self): - storage = make_live_r2_storage(location=unique_name("r2-missing-size")) - - assert storage.size("nonexistent.txt") == 0 - - def test_get_modified_time_returns_now_for_missing_file(self): - storage = make_live_r2_storage(location=unique_name("r2-missing-modified")) - - result = storage.get_modified_time("nonexistent.txt") - - assert abs((datetime.now() - result).total_seconds()) < 5 - - -class TestR2StoragePersistence: - def test_save_with_file_like_object(self): - storage = make_live_r2_storage(location=unique_name("r2-save-filelike")) - name = unique_name("test") + ".txt" - - assert save_bytes(storage, name, b"test content") == name - assert storage._read(name) == b"test content" - - def test_listdir_with_empty_path(self): - storage = make_live_r2_storage(location=unique_name("r2-empty-listdir")) - - directories, files = storage.listdir("") - - assert directories == [] - assert files == [] - - def test_listdir_scopes_to_directory_prefix(self): - storage = make_live_r2_storage(location=unique_name("r2-listdir-scope")) - - save_bytes(storage, "uploads/file.txt", b"file") - save_bytes(storage, "uploads2/other.txt", b"other") - - directories, files = storage.listdir("uploads") - - assert directories == [] - assert files == ["file.txt"] - - def test_get_available_name_raises_on_too_long(self): - storage = make_r2_storage() - - with pytest.raises(Exception, match="too long"): - storage.get_available_name("a" * 300, max_length=100) - - def test_get_available_name_returns_original_when_allow_overwrite(self): - storage = make_r2_storage(allow_overwrite=True) - - assert storage.get_available_name("file.txt") == "file.txt" - - def test_get_available_name_returns_original_when_not_exists(self): - storage = make_live_r2_storage(location=unique_name("r2-available-original")) - - assert storage.get_available_name("file.txt") == "file.txt" - - def test_get_available_name_increments_counter(self): - storage = make_live_r2_storage(location=unique_name("r2-available-counter")) - - save_bytes(storage, "file.txt", b"first") - save_bytes(storage, "file_1.txt", b"second") - - assert storage.get_available_name("file.txt") == "file_2.txt" - - def test_get_accessed_time_returns_modified_time(self): - storage = make_live_r2_storage(location=unique_name("r2-accessed-time")) - expected = datetime(2026, 1, 1, 12, 0, 0) - storage.get_modified_time = lambda name: expected - - assert storage.get_accessed_time("file.txt") == expected - - def test_get_created_time_returns_modified_time(self): - storage = make_live_r2_storage(location=unique_name("r2-created-time")) - expected = datetime(2026, 1, 1, 12, 0, 0) - storage.get_modified_time = lambda name: expected - - assert storage.get_created_time("file.txt") == expected - - -class TestR2FileClass: - def test_r2file_read_mode(self): - from django_cf.storage.r2 import R2File - - storage = make_live_r2_storage(location=unique_name("r2file-read")) - name = unique_name("read") + ".txt" - save_bytes(storage, name, b"test content") - - assert R2File(name, storage, mode="rb").read() == b"test content" - - def test_r2file_write_raises_in_read_mode(self): - from django_cf.storage.r2 import R2File - - storage = make_live_r2_storage(location=unique_name("r2file-readonly")) - name = unique_name("readonly") + ".txt" - save_bytes(storage, name, b"existing") - r2file = R2File(name, storage, mode="rb") - - with pytest.raises(AttributeError, match="not opened for writing"): - r2file.write(b"new content") - - def test_r2file_write_allowed_in_write_mode(self): - from django_cf.storage.r2 import R2File - - storage = make_live_r2_storage(location=unique_name("r2file-write")) - r2file = R2File(unique_name("write") + ".txt", storage, mode="wb") - - assert r2file.write(b"new content") == 11 - - def test_r2file_close(self): - from django_cf.storage.r2 import R2File - - storage = make_live_r2_storage(location=unique_name("r2file-close")) - name = unique_name("close") + ".txt" - save_bytes(storage, name, b"content") - r2file = R2File(name, storage) - _ = r2file.file - - assert r2file._file is not None - - r2file.close() - assert r2file._file.closed is True - - def test_djangocf_durable_object_get_app_error_message(self): - from django_cf import DjangoCFDurableObject - - with pytest.raises(NotImplementedError) as exc_info: - DjangoCFDurableObject.get_app(None) - - assert ( - str(exc_info.value) == "Please implement get_app in your django_cf worker" - ) - assert "implement implement" not in str(exc_info.value) diff --git a/packages/django-cf/tests/in_worker/worker/src/test_d1_backend.py b/packages/django-cf/tests/in_worker/worker/src/test_d1_backend.py new file mode 100644 index 00000000..d4e6ed5f --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_d1_backend.py @@ -0,0 +1,250 @@ +"""D1 backend tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +from types import SimpleNamespace + +import pytest +import workers + + +def make_d1_wrapper(defer_foreign_keys=False): + from django_cf.db.backends.d1.base import DatabaseWrapper + + wrapper = DatabaseWrapper.__new__(DatabaseWrapper) + cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) + wrapper.cursor = lambda: cursor_state + wrapper.binding = "DB" + wrapper.run_sync = lambda value: value + return wrapper + + +class FakeD1Statement: + def __init__( + self, *, raw_result=None, all_result=None, raw_error=None, all_error=None + ): + self.raw_result = raw_result or [] + self.all_result = all_result or {"results": [], "meta": {}} + self.raw_error = raw_error + self.all_error = all_error + self.bound_params = None + + def bind(self, *params): + self.bound_params = params + return self + + def raw(self): + if self.raw_error is not None: + raise self.raw_error + return self.raw_result + + def all(self): + if self.all_error is not None: + raise self.all_error + return self.all_result + + +class FakeD1Binding: + def __init__(self, statement): + self.statement = statement + self.prepared_queries = [] + + def prepare(self, query): + self.prepared_queries.append(query) + return self.statement + + +class TestD1DatabaseWrapperProcessQuery: + def test_process_query_no_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s", None + ) + + assert result_query == "SELECT * FROM users WHERE id = ?" + assert result_params is None + + def test_process_query_with_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s AND name = %s", [1, "test"] + ) + + assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" + assert result_params == [1, "test"] + + def test_process_query_null_param_replaced_with_literal(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name, email) VALUES (%s, %s)", ["test", None] + ) + + assert "null" in result_query + assert result_params == ["test"] + + def test_process_query_all_null_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name, email) VALUES (%s, %s)", [None, None] + ) + + assert result_query == "INSERT INTO users (name, email) VALUES (null, null)" + assert result_params == [] + + def test_process_query_mixed_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "UPDATE users SET name = %s, email = %s, age = %s WHERE id = %s", + ["test", None, 25, None], + ) + + assert result_params == ["test", 25] + assert result_query.count("?") == 2 + assert result_query.count("null") == 2 + + def test_process_query_with_defer_foreign_keys(self): + result = make_d1_wrapper(True).process_query( + "INSERT INTO users (name) VALUES (%s)", ["test"] + ) + + assert "PRAGMA defer_foreign_keys = on" in result + assert "PRAGMA defer_foreign_keys = off" in result + + def test_process_query_date_trunc_replacement(self): + wrapper = make_d1_wrapper() + + result_query, _ = wrapper.process_query( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", + ["year", "UTC", "UTC"], + ) + + assert "django_date_trunc" not in result_query + assert "CASE" in result_query or "STRFTIME" in result_query + + +class TestD1DatabaseWrapperConfiguration: + def test_vendor_name(self): + from django_cf.db.backends.d1.base import DatabaseWrapper + + assert DatabaseWrapper.vendor == "cloudflare_d1" + + def test_display_name(self): + from django_cf.db.backends.d1.base import DatabaseWrapper + + assert DatabaseWrapper.display_name == "D1" + + +class TestD1GetConnectionParams: + def test_missing_binding_raises_error(self): + from django.core.exceptions import ImproperlyConfigured + + wrapper = make_d1_wrapper() + wrapper.settings_dict = {"CLOUDFLARE_BINDING": None} + + with pytest.raises(ImproperlyConfigured) as exc_info: + wrapper.get_connection_params() + + assert "CLOUDFLARE_BINDING" in str(exc_info.value) + + def test_valid_binding_returns_params(self): + wrapper = make_d1_wrapper() + wrapper.settings_dict = {"CLOUDFLARE_BINDING": "MY_DB"} + + assert wrapper.get_connection_params() == {"binding": "MY_DB"} + + +class TestD1ExceptionHandling: + @pytest.mark.xfail( + reason=( + "The `except Exception: raise Error(Error.new().stack)` handler in `run_query` swallows this and re-raises a JS Error. Removing that handler is a separate change; these pass once it lands." + ), + strict=True, + ) + def test_run_query_lets_binding_errors_propagate(self, monkeypatch): + wrapper = make_d1_wrapper() + error = RuntimeError("binding boom") + monkeypatch.setattr( + workers, + "env", + SimpleNamespace(DB=FakeD1Binding(FakeD1Statement(raw_error=error))), + ) + + with pytest.raises(RuntimeError, match="binding boom"): + wrapper.run_query("SELECT * FROM test") + + +class TestD1RunQuery: + def test_read_query_returns_rows(self, monkeypatch): + wrapper = make_d1_wrapper() + statement = FakeD1Statement(raw_result=[[1, "hello"], [2, "world"]]) + binding = FakeD1Binding(statement) + monkeypatch.setattr(workers, "env", SimpleNamespace(DB=binding)) + + result = wrapper.run_query("SELECT * FROM test WHERE id = %s", [1]) + + assert list(result) == [(1, "hello"), (2, "world")] + assert statement.bound_params == (1,) + assert binding.prepared_queries == ["SELECT * FROM test WHERE id = ?"] + + def test_write_query_returns_meta(self, monkeypatch): + wrapper = make_d1_wrapper() + statement = FakeD1Statement( + all_result={ + "results": [["ok"]], + "meta": {"rows_read": 1, "rows_written": 2, "last_row_id": 9}, + } + ) + monkeypatch.setattr( + workers, "env", SimpleNamespace(DB=FakeD1Binding(statement)) + ) + + result = wrapper.run_query("INSERT INTO test VALUES (%s)", ["x"]) + + assert list(result) == [("ok",)] + assert result.rowcount == 2 + assert result.lastrowid == 9 + + +class TestD1ParameterHandling: + def test_empty_params_list(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query("SELECT * FROM users", []) + + assert result_query == "SELECT * FROM users" + assert result_params == [] + + def test_special_characters_in_params(self): + wrapper = make_d1_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name) VALUES (%s)", ["test'; DROP TABLE users; --"] + ) + + assert result_params == ["test'; DROP TABLE users; --"] + assert "?" in result_query + + def test_unicode_params(self): + wrapper = make_d1_wrapper() + + _, result_params = wrapper.process_query( + "INSERT INTO users (name) VALUES (%s)", [""] + ) + + assert result_params == [""] + + def test_large_number_of_params(self): + wrapper = make_d1_wrapper() + + placeholders = ", ".join(["%s"] * 50) + result_query, result_params = wrapper.process_query( + f"INSERT INTO test VALUES ({placeholders})", list(range(50)) + ) + + assert result_query.count("?") == 50 + assert len(result_params) == 50 diff --git a/packages/django-cf/tests/in_worker/worker/src/test_do_backend.py b/packages/django-cf/tests/in_worker/worker/src/test_do_backend.py new file mode 100644 index 00000000..9505928b --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_do_backend.py @@ -0,0 +1,200 @@ +"""Durable Objects backend tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +from types import SimpleNamespace + +import pytest + + +def make_d1_wrapper(defer_foreign_keys=False): + from django_cf.db.backends.d1.base import DatabaseWrapper + + wrapper = DatabaseWrapper.__new__(DatabaseWrapper) + cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) + wrapper.cursor = lambda: cursor_state + wrapper.binding = "DB" + wrapper.run_sync = lambda value: value + return wrapper + + +def make_do_wrapper(defer_foreign_keys=False): + from django_cf.db.backends.do.base import DatabaseWrapper + + wrapper = DatabaseWrapper.__new__(DatabaseWrapper) + cursor_state = SimpleNamespace(_defer_foreign_keys=defer_foreign_keys) + wrapper.cursor = lambda: cursor_state + return wrapper + + +class FakeDOArray: + def __init__(self, data): + self.data = data + + def toArray(self): + return self + + def to_py(self): + return self.data + + +class FakeDOStatement: + def __init__(self, data, *, rows_read=0, rows_written=0): + self.data = data + self.rowsRead = rows_read + self.rowsWritten = rows_written + + def raw(self): + return FakeDOArray(self.data) + + +class FakeDOStorage: + def __init__(self, statement=None, *, error=None): + self.statement = statement + self.error = error + self.calls = [] + + def exec(self, query, *params): + if self.error is not None: + raise self.error + self.calls.append((query, params)) + return self.statement + + +class TestDODatabaseWrapperProcessQuery: + def test_process_query_no_params(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s", None + ) + + assert result_query == "SELECT * FROM users WHERE id = ?" + assert result_params is None + + def test_process_query_with_params(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "SELECT * FROM users WHERE id = %s AND name = %s", [1, "test"] + ) + + assert result_query == "SELECT * FROM users WHERE id = ? AND name = ?" + assert result_params == [1, "test"] + + def test_process_query_null_param_replaced_with_literal(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO users (name, email) VALUES (%s, %s)", ["test", None] + ) + + assert "null" in result_query + assert result_params == ["test"] + + def test_process_query_with_defer_foreign_keys(self): + result = make_do_wrapper(True).process_query( + "INSERT INTO users (name) VALUES (%s)", ["test"] + ) + + assert "PRAGMA defer_foreign_keys = on" in result + assert "PRAGMA defer_foreign_keys = off" in result + + +class TestDODatabaseWrapperConfiguration: + def test_vendor_name(self): + from django_cf.db.backends.do.base import DatabaseWrapper + + assert DatabaseWrapper.vendor == "cloudflare_durable_objects" + assert DatabaseWrapper.display_name == "DO" + + def test_get_connection_params_returns_empty(self): + assert make_do_wrapper().get_connection_params() == {} + + +class TestDOMissingDateTruncBug: + def test_do_process_query_missing_date_trunc(self): + d1_result, _ = make_d1_wrapper().process_query( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", + ["year", "UTC", "UTC"], + ) + do_result, _ = make_do_wrapper().process_query( + "SELECT django_date_trunc(%s, created_at, %s, %s) FROM orders", + ["year", "UTC", "UTC"], + ) + + assert "django_date_trunc" not in d1_result + assert "django_date_trunc" in do_result + + +class TestDOStorageInitialization: + def test_storage_module_exists(self): + from django_cf.db.backends.do import storage + + storage.set_storage(None) + assert storage.get_storage() is None + + def test_run_query_uses_configured_storage(self): + from django_cf.db.backends.do import storage + + fake_storage = FakeDOStorage(FakeDOStatement([[1, "ok"]], rows_read=1)) + storage.set_storage(fake_storage) + + result = make_do_wrapper().run_query("SELECT * FROM test WHERE id = %s", [1]) + + assert list(result) == [(1, "ok")] + assert fake_storage.calls == [("SELECT * FROM test WHERE id = ?", (1,))] + + +class TestDOQueryExecution: + def test_read_query_uses_raw(self): + from django_cf.db.backends.do import storage + + fake_storage = FakeDOStorage(FakeDOStatement([], rows_read=0, rows_written=0)) + storage.set_storage(fake_storage) + + result = make_do_wrapper().run_query("SELECT * FROM users") + + assert result.fetchall() == [] + + +class TestDOExceptionHandling: + def test_run_query_lets_binding_errors_propagate(self): + from django_cf.db.backends.do import storage + + storage.set_storage(FakeDOStorage(error=RuntimeError("storage boom"))) + + with pytest.raises(RuntimeError, match="storage boom"): + make_do_wrapper().run_query("SELECT * FROM test") + + +class TestDOParamConversion: + def test_boolean_true_not_converted_in_process_query(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO test (active) VALUES (%s)", [True] + ) + + assert result_query == "INSERT INTO test (active) VALUES (?)" + assert result_params == [True] + + def test_multiple_none_params_order_preserved(self): + wrapper = make_do_wrapper() + + result_query, result_params = wrapper.process_query( + "INSERT INTO test (a, b, c, d) VALUES (%s, %s, %s, %s)", + [1, None, 2, None], + ) + + assert result_params == [1, 2] + assert result_query == "INSERT INTO test (a, b, c, d) VALUES (?, null, ?, null)" + + +class TestDOPragmaReturnTypeBug: + def test_pragma_returns_string_not_tuple(self): + result = make_do_wrapper(True).process_query( + "INSERT INTO users (name) VALUES (%s)", ["test"] + ) + + assert isinstance(result, str) diff --git a/packages/django-cf/tests/in_worker/worker/src/test_storage_errors.py b/packages/django-cf/tests/in_worker/worker/src/test_storage_errors.py new file mode 100644 index 00000000..c6d0808f --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_storage_errors.py @@ -0,0 +1,247 @@ +"""R2 storage tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +from datetime import datetime +from io import BytesIO +from types import SimpleNamespace +from uuid import uuid4 + +import django.conf +import pytest +import workers + + +def unique_name(prefix: str) -> str: + return f"{prefix}-{uuid4().hex}" + + +def make_r2_storage(**kwargs): + from django_cf.storage.r2 import R2Storage + + return R2Storage(**kwargs) + + +def make_live_r2_storage(**kwargs): + pyodide_run_sync = None + try: + from pyodide.ffi import run_sync as pyodide_run_sync + except ImportError: + pytest.skip("R2Storage requires pyodide.ffi.run_sync (JSPI)") + assert pyodide_run_sync is not None + + storage = make_r2_storage(**kwargs) + storage._bucket = workers.env.BUCKET + storage._run_sync = pyodide_run_sync + return storage + + +def save_bytes(storage, name: str, data: bytes) -> str: + return storage._save(name, BytesIO(data)) + + +class TestR2StorageInitialization: + def test_init_default_values(self): + storage = make_r2_storage() + + assert storage.binding == "BUCKET" + assert storage.location == "" + assert storage.allow_overwrite is False + assert storage._bucket is None + + def test_init_custom_values(self): + storage = make_r2_storage( + binding="MY_BUCKET", location="uploads/", allow_overwrite=True + ) + + assert storage.binding == "MY_BUCKET" + assert storage.location == "uploads" + assert storage.allow_overwrite is True + + def test_init_strips_slashes_from_location(self): + assert make_r2_storage(location="/uploads/files/").location == "uploads/files" + + +class TestR2StorageFullPath: + def test_full_path_no_location(self): + assert ( + make_r2_storage(location="")._full_path("test/file.txt") == "test/file.txt" + ) + + def test_full_path_with_location(self): + assert ( + make_r2_storage(location="uploads")._full_path("test/file.txt") + == "uploads/test/file.txt" + ) + + +class TestR2StorageUrlErrors: + def test_url_raises_without_media_url(self, monkeypatch): + storage = make_r2_storage() + + monkeypatch.setattr(django.conf, "settings", SimpleNamespace()) + + with pytest.raises(ValueError, match="MEDIA_URL must be configured"): + storage.url("file.txt") + + def test_url_raises_with_empty_media_url(self, monkeypatch): + storage = make_r2_storage() + + monkeypatch.setattr(django.conf, "settings", SimpleNamespace(MEDIA_URL="")) + + with pytest.raises(ValueError, match="MEDIA_URL must be configured"): + storage.url("file.txt") + + def test_url_constructs_correct_path(self, monkeypatch): + storage = make_r2_storage(location="uploads") + monkeypatch.setattr( + django.conf, "settings", SimpleNamespace(MEDIA_URL="/media/") + ) + + assert storage.url("file.txt") == "/media/uploads/file.txt" + + +class TestR2StorageReadErrors: + def test_read_returns_none_on_not_found(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-read")) + + assert storage._read("nonexistent.txt") is None + + +class TestR2StorageExistsErrors: + def test_exists_returns_false_on_not_found(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-exists")) + + assert storage.exists("nonexistent.txt") is False + + +class TestR2StorageSizeErrors: + def test_size_returns_zero_on_not_found(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-size")) + + assert storage.size("nonexistent.txt") == 0 + + +class TestR2StorageGetModifiedTimeErrors: + def test_get_modified_time_returns_now_for_missing_file(self): + storage = make_live_r2_storage(location=unique_name("r2-missing-modified")) + + result = storage.get_modified_time("nonexistent.txt") + + assert abs((datetime.now() - result).total_seconds()) < 5 + + +class TestR2StorageGetAvailableName: + def test_get_available_name_raises_on_too_long(self): + storage = make_r2_storage() + + with pytest.raises(Exception, match="too long"): + storage.get_available_name("a" * 300, max_length=100) + + def test_get_available_name_returns_original_when_allow_overwrite(self): + storage = make_r2_storage(allow_overwrite=True) + + assert storage.get_available_name("file.txt") == "file.txt" + + def test_get_available_name_returns_original_when_not_exists(self): + storage = make_live_r2_storage(location=unique_name("r2-available-original")) + + assert storage.get_available_name("file.txt") == "file.txt" + + def test_get_available_name_increments_counter(self): + storage = make_live_r2_storage(location=unique_name("r2-available-counter")) + + save_bytes(storage, "file.txt", b"first") + save_bytes(storage, "file_1.txt", b"second") + + assert storage.get_available_name("file.txt") == "file_2.txt" + + +class TestR2FileClass: + def test_r2file_read_mode(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-read")) + name = unique_name("read") + ".txt" + save_bytes(storage, name, b"test content") + + assert R2File(name, storage, mode="rb").read() == b"test content" + + def test_r2file_write_raises_in_read_mode(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-readonly")) + name = unique_name("readonly") + ".txt" + save_bytes(storage, name, b"existing") + r2file = R2File(name, storage, mode="rb") + + with pytest.raises(AttributeError, match="not opened for writing"): + r2file.write(b"new content") + + def test_r2file_write_allowed_in_write_mode(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-write")) + r2file = R2File(unique_name("write") + ".txt", storage, mode="wb") + + assert r2file.write(b"new content") == 11 + + def test_r2file_close(self): + from django_cf.storage.r2 import R2File + + storage = make_live_r2_storage(location=unique_name("r2file-close")) + name = unique_name("close") + ".txt" + save_bytes(storage, name, b"content") + r2file = R2File(name, storage) + _ = r2file.file + + assert r2file._file is not None + + r2file.close() + assert r2file._file.closed is True + + +class TestR2StorageSaveEdgeCases: + def test_save_with_file_like_object(self): + storage = make_live_r2_storage(location=unique_name("r2-save-filelike")) + name = unique_name("test") + ".txt" + + assert save_bytes(storage, name, b"test content") == name + assert storage._read(name) == b"test content" + + +class TestR2StorageListdirEdgeCases: + def test_listdir_with_empty_path(self): + storage = make_live_r2_storage(location=unique_name("r2-empty-listdir")) + + directories, files = storage.listdir("") + + assert directories == [] + assert files == [] + + def test_listdir_scopes_to_directory_prefix(self): + storage = make_live_r2_storage(location=unique_name("r2-listdir-scope")) + + save_bytes(storage, "uploads/file.txt", b"file") + save_bytes(storage, "uploads2/other.txt", b"other") + + directories, files = storage.listdir("uploads") + + assert directories == [] + assert files == ["file.txt"] + + +class TestR2StorageTimeMethodsFallback: + def test_get_accessed_time_returns_modified_time(self): + storage = make_live_r2_storage(location=unique_name("r2-accessed-time")) + expected = datetime(2026, 1, 1, 12, 0, 0) + storage.get_modified_time = lambda name: expected + + assert storage.get_accessed_time("file.txt") == expected + + def test_get_created_time_returns_modified_time(self): + storage = make_live_r2_storage(location=unique_name("r2-created-time")) + expected = datetime(2026, 1, 1, 12, 0, 0) + storage.get_modified_time = lambda name: expected + + assert storage.get_created_time("file.txt") == expected diff --git a/packages/django-cf/tests/in_worker/worker/src/test_wsgi_handler.py b/packages/django-cf/tests/in_worker/worker/src/test_wsgi_handler.py new file mode 100644 index 00000000..99d6fecb --- /dev/null +++ b/packages/django-cf/tests/in_worker/worker/src/test_wsgi_handler.py @@ -0,0 +1,30 @@ +"""WSGI handler tests executed inside workerd.""" + +# pyright: reportMissingImports=false + +import pytest + + +class TestDjangoCFErrorMessages: + def test_djangocf_get_app_error_message(self): + from django_cf import DjangoCF + + cf = DjangoCF() + with pytest.raises(NotImplementedError) as exc_info: + cf.get_app() + + assert ( + str(exc_info.value) == "Please implement get_app in your django_cf worker" + ) + assert "implement implement" not in str(exc_info.value) + + def test_djangocf_durable_object_get_app_error_message(self): + from django_cf import DjangoCFDurableObject + + with pytest.raises(NotImplementedError) as exc_info: + DjangoCFDurableObject.get_app(None) + + assert ( + str(exc_info.value) == "Please implement get_app in your django_cf worker" + ) + assert "implement implement" not in str(exc_info.value) From adf8e122ae72dd53828965183ee7fb379b38ce0a Mon Sep 17 00:00:00 2001 From: Gyeongjae Choi Date: Wed, 12 Aug 2026 11:50:40 +0900 Subject: [PATCH 7/7] chore: cleanup comments --- packages/django-cf/AGENTS.md | 5 +---- packages/django-cf/tests/conftest.py | 18 +----------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/packages/django-cf/AGENTS.md b/packages/django-cf/AGENTS.md index 6516bcfc..7bb7891a 100644 --- a/packages/django-cf/AGENTS.md +++ b/packages/django-cf/AGENTS.md @@ -18,7 +18,6 @@ It is published to PyPI as `django-cf` and imported as `django_cf`. - Both database backends have transactions **disabled**. Every query commits immediately and rollbacks are unavailable, so code that relies on `atomic()` for correctness will not behave as it does on other backends. - This package still uses a flat layout (`django_cf/`) and a setuptools build backend, unlike `packages/cli` and `packages/runtime-sdk` which use src-layout and hatchling. - `templates/d1/` and `templates/durable-objects/` are deployable example projects, and `tests/servers/r2/` is a fixture app used by the R2 integration tests. None of them are part of the wheel. -- Ruff is configured in `pyproject.toml`. `target-version` is `py312` here because the package declares `requires-python = ">=3.12"`; the two sibling packages target `py311`. - That ruff config deliberately ignores `B904`, `B905`, `C901`, `PERF401`, `PLR0911`, `PLR0912`, `PLR0913`, `PLR0915`, `PLR2004`, `PLW0603` and `UP038`, because fixing the existing violations would mean behavioural or structural changes. Write new code that does not need those ignores. - mypy and semgrep currently skip this package. Adding either is a deliberate follow-up, not something to switch on incidentally. @@ -28,7 +27,5 @@ It is published to PyPI as `django-cf` and imported as `django_cf`. - Every suite needs a Node toolchain, because every suite runs against a real Worker. Run them from `packages/django-cf` with `uv run --frozen pytest tests`; the `django-test` job in `.github/workflows/tests.yml` runs the same command. - `tests/conftest.py` is the whole harness. It copies the worker project into a tmpdir, runs `pywrangler sync`, overwrites the vendored `django_cf/` with the working tree, then starts `pywrangler dev` on a free port. Nothing is installed into the repository, so there is no setup step to re-run after editing library files. - Two shapes of suite: - - `tests/d1/`, `tests/durable_objects/`, `tests/r2/` and `tests/test_date_trunc.py` drive a deployed Django app over HTTP, via the session-scoped `d1_web_server`, `durable_objects_web_server` and `r2_web_server` fixtures. Those apps live in `templates/` and `tests/servers/r2/` and expose management endpoints for setup, such as `/__run_migrations__/` and `/__create_admin__/`, which creates an admin user with username `admin` and password `password`. + - `tests/d1/`, `tests/durable_objects/`, `tests/r2/` drive a deployed Django app over HTTP, via the session-scoped `d1_web_server`, `durable_objects_web_server` and `r2_web_server` fixtures. Those apps live in `templates/` and `tests/servers/r2/` and expose management endpoints for setup, such as `/__run_migrations__/` and `/__create_admin__/`, which creates an admin user with username `admin` and password `password`. - `tests/in_worker/` runs pytest *inside* workerd. The real test bodies are `tests/in_worker/worker/src/test_*.py`; `register_in_worker_suites` discovers them by AST and generates one host-side test per in-worker test, so a failure inside the Worker surfaces as an ordinary pytest failure. `pyproject.toml` ignores that `src` directory so the host collector does not try to import Worker-only modules. -- The in-worker suites run once per entry in `COMPAT_CONFIGS`, currently the 3.13 and 3.14 runtimes. See the `tests/in_worker/test_in_worker.py` docstring for why 3.12 is excluded. -- `tests/e2e/` is excluded via `addopts` and does not run. Most of it duplicates the D1, DO and R2 suites; the cases only it covers are concurrent requests, a 1MB R2 upload and a binary-download `xfail`. Re-enable it or delete it rather than leaving it half-alive. diff --git a/packages/django-cf/tests/conftest.py b/packages/django-cf/tests/conftest.py index 3bca1ae4..02b7aff9 100644 --- a/packages/django-cf/tests/conftest.py +++ b/packages/django-cf/tests/conftest.py @@ -1,22 +1,6 @@ """Host-side fixtures that serve the test workers with ``pywrangler dev``. -Two kinds of worker are served from here: - -* The deployable apps under ``templates/`` and ``tests/servers/``, which back - the D1, Durable Objects, R2 and date-trunc suites. Each ``*_web_server`` - fixture starts one, applies migrations, creates the admin user and hands out - its base URL. These run in uv *project* mode because their ``wrangler.jsonc`` - declares a ``build.command`` that shells out to - ``python manage.py collectstatic``, which needs the project's own virtualenv - on PATH; ``uv run --no-project`` would leave Django unimportable and the build - would fail before the worker starts. -* ``tests/in_worker/worker``, which runs pytest *inside* workerd and reports - each result over HTTP. ``register_in_worker_suites`` mirrors those results - back into host-side test functions, once per entry in the compat matrix. - -Ported from ``packages/runtime-sdk/tests/conftest.py``. The overlap with that -copy is deliberate: each package keeps a self-contained harness rather than -importing a sibling package's test helpers. +TODO: reduce the duplication between this file and packages/runtime-sdk/tests/conftest.py """ # pyright: reportMissingImports=false, reportMissingModuleSource=false