Full test infrastructure, code audit fixes, and real E2E integration tests
Test infrastructure: - Django standalone test runner (pytest-django, test settings, EmailUser model) - React unit tests via Vitest with jsdom, jest compat layer, path aliases - Playwright E2E tests using generated hooks in a real Chromium browser - Docker Compose test backend (Django + Redis) for integration testing - Desktop integration test app (PyWebView + Django + uvicorn) - Makefile with test/test-django/test-react/test-integration targets Library bugs found and fixed: - hasJWT truthiness: undefined !== null was true, skipping session init - process.env crash: CSR client referenced process.env in non-Node browsers - baseUrl not forwarded: DjareaProvider didn't pass baseUrl to CSR client - Relative URL handling: new URL() failed with relative base paths - call() race condition: HTTP requests fired before CSRF cookie was set - Session init await: added sessionRef promise so call() waits for session - path_prefix on schema export: both export commands failed with URL reverse - NullBooleanField removed: referenced field doesn't exist in Django 5.0+ - lru_cache on JWT settings: get_settings() now cached as intended - Channel message routing: broadcasts now include channel name and params - httpFunctionCall: fixed URL and request body format Generator fixes: - Removed 1,100 lines of REST/OpenAPI client generation (not part of Djarea) - Generator now works for djarea-only projects without django-ninja REST APIs - Generated DjangoContext now includes ChannelProvider when channels exist - Fixed env var passthrough for schema export commands - Deduplicated fetch logic into single runDjangoCommand helper Test quality: - Fixed 33 tautological Django tests with real assertions - Found hidden bug: benchmark functions were never registered - Found hidden bug: unicode lookalike test used plain ASCII - Deleted worthless React unit tests (duplicates, shape checks, Zod-tests-Zod) - Replaced jsdom integration tests with Playwright browser tests Example apps: - example/: Integration test backend with 33 server functions, 5 forms, 4 channels covering auth variations, contexts, class-based ServerFunction, error codes, DjareaFormMixin, formsets, and JWT - desktop/: PyWebView desktop app with file system access, SQLite CRUD, system introspection, and 39 real HTTP integration tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
example/manage.py
Normal file
10
example/manage.py
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
|
||||
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
execute_from_command_line(sys.argv)
|
||||
0
example/testapp/__init__.py
Normal file
0
example/testapp/__init__.py
Normal file
9
example/testapp/apps.py
Normal file
9
example/testapp/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TestAppConfig(AppConfig):
|
||||
name = "testapp"
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
|
||||
def ready(self):
|
||||
import testapp.djarea_clients # noqa: F401
|
||||
14
example/testapp/asgi.py
Normal file
14
example/testapp/asgi.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import os
|
||||
|
||||
import django
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings")
|
||||
django.setup()
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from djarea import wrap_asgi
|
||||
|
||||
# Register server functions and channels before building the ASGI app
|
||||
import testapp.djarea_clients # noqa: F401
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
393
example/testapp/djarea_clients.py
Normal file
393
example/testapp/djarea_clients.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""
|
||||
Server functions and channels for integration tests.
|
||||
|
||||
Registers everything the React integration test suite expects:
|
||||
- echo, add (HTTP + WebSocket RPC)
|
||||
- login, signup, add_email forms
|
||||
- chat, notifications, presence channels
|
||||
"""
|
||||
|
||||
from django import forms
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import ServerFunction, client
|
||||
from djarea.channels import ReactChannel
|
||||
from djarea.setup.registry import register, register_form, register_as
|
||||
from djarea.channels import register as register_channel
|
||||
from djarea.forms import DjareaFormMixin, DjareaFormMeta
|
||||
from djarea.jwt import jwt_obtain, jwt_refresh
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Server Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def echo(request: HttpRequest, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=text)
|
||||
|
||||
|
||||
register(echo, "echo")
|
||||
|
||||
|
||||
class AddOutput(BaseModel):
|
||||
result: int
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def add(request: HttpRequest, a: int, b: int) -> AddOutput:
|
||||
return AddOutput(result=a + b)
|
||||
|
||||
|
||||
register(add, "add")
|
||||
|
||||
|
||||
class WhoamiOutput(BaseModel):
|
||||
user_id: int | None
|
||||
email: str
|
||||
is_staff: bool
|
||||
|
||||
|
||||
@client(auth=True)
|
||||
def whoami(request: HttpRequest) -> WhoamiOutput:
|
||||
return WhoamiOutput(
|
||||
user_id=getattr(request.user, 'id', None),
|
||||
email=getattr(request.user, 'email', ''),
|
||||
is_staff=getattr(request.user, 'is_staff', False),
|
||||
)
|
||||
|
||||
|
||||
register(whoami, "whoami")
|
||||
|
||||
|
||||
@client
|
||||
def http_only_echo(request: HttpRequest, text: str) -> EchoOutput:
|
||||
return EchoOutput(message=text)
|
||||
|
||||
|
||||
register(http_only_echo, "http_only_echo")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Forms
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
login = forms.CharField(max_length=150, label="Login")
|
||||
password = forms.CharField(widget=forms.PasswordInput, label="Password")
|
||||
|
||||
|
||||
def handle_login(request, form):
|
||||
"""Login form submit handler."""
|
||||
from django.contrib.auth import authenticate, login
|
||||
|
||||
user = authenticate(
|
||||
request,
|
||||
username=form.cleaned_data["login"],
|
||||
password=form.cleaned_data["password"],
|
||||
)
|
||||
if user is not None:
|
||||
login(request, user)
|
||||
return {"success": True}
|
||||
form.add_error(None, "Invalid login credentials.")
|
||||
return None # Signals validation failure
|
||||
|
||||
|
||||
register_form(LoginForm, "login", submit_handler=handle_login)
|
||||
|
||||
|
||||
class SignupForm(forms.Form):
|
||||
email = forms.EmailField(label="Email")
|
||||
password1 = forms.CharField(widget=forms.PasswordInput, label="Password")
|
||||
|
||||
|
||||
def handle_signup(request, form):
|
||||
"""Signup form submit handler."""
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.create_user(
|
||||
email=form.cleaned_data["email"],
|
||||
password=form.cleaned_data["password1"],
|
||||
)
|
||||
return {"success": True, "data": {"user_id": user.pk}}
|
||||
except Exception as e:
|
||||
form.add_error(None, str(e))
|
||||
return None
|
||||
|
||||
|
||||
register_form(SignupForm, "signup", submit_handler=handle_signup)
|
||||
|
||||
|
||||
class AddEmailForm(forms.Form):
|
||||
email = forms.EmailField(label="Email address")
|
||||
|
||||
|
||||
register_form(AddEmailForm, "add_email")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channels
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ChatChannel(ReactChannel):
|
||||
class Params(BaseModel):
|
||||
room: str
|
||||
|
||||
class ReactMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
room = params.room if params else "default"
|
||||
return f"chat_{room}"
|
||||
|
||||
def receive(self, params, msg):
|
||||
return self.DjangoMessage(text=msg.text)
|
||||
|
||||
|
||||
register_channel(ChatChannel, "chat")
|
||||
|
||||
|
||||
class NotificationsChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "notifications_global"
|
||||
|
||||
|
||||
register_channel(NotificationsChannel, "notifications")
|
||||
|
||||
|
||||
class PresenceChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
value: int
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "presence_global"
|
||||
|
||||
|
||||
register_channel(PresenceChannel, "presence")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth Variations
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# --- Staff-only ---
|
||||
@client(auth='staff')
|
||||
def staff_only(request: HttpRequest) -> EchoOutput:
|
||||
return EchoOutput(message=f"staff:{request.user.email}")
|
||||
|
||||
register(staff_only, "staff_only")
|
||||
|
||||
|
||||
# --- Superuser-only ---
|
||||
@client(auth='superuser')
|
||||
def superuser_only(request: HttpRequest) -> EchoOutput:
|
||||
return EchoOutput(message=f"superuser:{request.user.email}")
|
||||
|
||||
register(superuser_only, "superuser_only")
|
||||
|
||||
|
||||
# --- Callable auth ---
|
||||
def check_verified_email(request):
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
return getattr(request.user, 'email', '').endswith('@verified.com')
|
||||
|
||||
@client(auth=check_verified_email)
|
||||
def verified_only(request: HttpRequest) -> EchoOutput:
|
||||
return EchoOutput(message="verified")
|
||||
|
||||
register(verified_only, "verified_only")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Context Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CurrentUserOutput(BaseModel):
|
||||
authenticated: bool
|
||||
email: str
|
||||
is_staff: bool
|
||||
|
||||
@client(context='global')
|
||||
def current_user(request: HttpRequest) -> CurrentUserOutput:
|
||||
if request.user.is_authenticated:
|
||||
return CurrentUserOutput(
|
||||
authenticated=True,
|
||||
email=request.user.email,
|
||||
is_staff=request.user.is_staff,
|
||||
)
|
||||
return CurrentUserOutput(authenticated=False, email="", is_staff=False)
|
||||
|
||||
register(current_user, "current_user")
|
||||
|
||||
|
||||
class GreetOutput(BaseModel):
|
||||
greeting: str
|
||||
|
||||
@client(context='local')
|
||||
def greet(request: HttpRequest, name: str) -> GreetOutput:
|
||||
return GreetOutput(greeting=f"Hello, {name}!")
|
||||
|
||||
register(greet, "greet")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Class-based ServerFunction
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MultiplyInput(BaseModel):
|
||||
x: int
|
||||
y: int
|
||||
|
||||
class MultiplyOutput(BaseModel):
|
||||
product: int
|
||||
|
||||
@register_as("multiply")
|
||||
class Multiply(ServerFunction):
|
||||
Input = MultiplyInput
|
||||
Output = MultiplyOutput
|
||||
|
||||
def call(self, input: MultiplyInput) -> MultiplyOutput:
|
||||
return MultiplyOutput(product=input.x * input.y)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Error-producing Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@client
|
||||
def not_implemented_fn(request: HttpRequest) -> EchoOutput:
|
||||
raise NotImplementedError("This feature is not yet implemented")
|
||||
|
||||
register(not_implemented_fn, "not_implemented_fn")
|
||||
|
||||
|
||||
@client
|
||||
def buggy_fn(request: HttpRequest) -> EchoOutput:
|
||||
raise RuntimeError("Unexpected internal failure")
|
||||
|
||||
register(buggy_fn, "buggy_fn")
|
||||
|
||||
|
||||
@client
|
||||
def permission_check_fn(request: HttpRequest, secret: str) -> EchoOutput:
|
||||
if secret != "open-sesame":
|
||||
raise PermissionError("Wrong secret")
|
||||
return EchoOutput(message="access granted")
|
||||
|
||||
register(permission_check_fn, "permission_check_fn")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# WebSocket + Auth Function
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@client(websocket=True, auth=True)
|
||||
def ws_whoami(request: HttpRequest) -> WhoamiOutput:
|
||||
return WhoamiOutput(
|
||||
user_id=getattr(request.user, 'id', None),
|
||||
email=getattr(request.user, 'email', ''),
|
||||
is_staff=getattr(request.user, 'is_staff', False),
|
||||
)
|
||||
|
||||
register(ws_whoami, "ws_whoami")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DjareaFormMixin Forms
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ContactForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
name="contact",
|
||||
title="Contact Us",
|
||||
subtitle="We'd love to hear from you",
|
||||
submit_label="Send Message",
|
||||
live_validation=True,
|
||||
live_form_errors=False,
|
||||
)
|
||||
|
||||
name = forms.CharField(max_length=100, label="Your Name")
|
||||
email = forms.EmailField(label="Email Address")
|
||||
message = forms.CharField(widget=forms.Textarea, label="Message")
|
||||
|
||||
def on_submit_success(self, request):
|
||||
return {"received": True, "from": self.cleaned_data["email"]}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Formset-enabled Form
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ItemForm(DjareaFormMixin, forms.Form):
|
||||
djarea = DjareaFormMeta(
|
||||
name="item",
|
||||
title="Items",
|
||||
submit_label="Save Items",
|
||||
enable_formset=True,
|
||||
)
|
||||
|
||||
label = forms.CharField(max_length=50, label="Item Label")
|
||||
quantity = forms.IntegerField(min_value=1, label="Quantity")
|
||||
|
||||
def on_submit_success(self, request):
|
||||
return {"label": self.cleaned_data["label"], "qty": self.cleaned_data["quantity"]}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth-gated Channel
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PrivateChannel(ReactChannel):
|
||||
class DjangoMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return getattr(self.user, 'is_authenticated', False)
|
||||
|
||||
def group(self, params=None):
|
||||
return "private_global"
|
||||
|
||||
register_channel(PrivateChannel, "private")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# JWT Function Registration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
register(jwt_obtain, "jwt_obtain")
|
||||
register(jwt_refresh, "jwt_refresh")
|
||||
29
example/testapp/models.py
Normal file
29
example/testapp/models.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
||||
from django.db import models
|
||||
|
||||
|
||||
class EmailUserManager(BaseUserManager):
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
if not email:
|
||||
raise ValueError("Email is required")
|
||||
email = self.normalize_email(email)
|
||||
user = self.model(email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, email, password=None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
return self.create_user(email, password, **extra_fields)
|
||||
|
||||
|
||||
class EmailUser(AbstractBaseUser, PermissionsMixin):
|
||||
email = models.EmailField(unique=True)
|
||||
is_staff = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
objects = EmailUserManager()
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
76
example/testapp/settings.py
Normal file
76
example/testapp/settings.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Django settings for the integration test backend.
|
||||
|
||||
Provides:
|
||||
- HTTP server functions (echo, add)
|
||||
- WebSocket channels (chat, notifications, presence)
|
||||
- JWT authentication
|
||||
- Form integration (login, signup, add_email)
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
SECRET_KEY = "integration-test-secret-key-not-for-production"
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"djarea",
|
||||
"testapp",
|
||||
]
|
||||
|
||||
AUTH_USER_MODEL = "testapp.EmailUser"
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "testapp.urls"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": os.path.join(os.path.dirname(__file__), "..", "db.sqlite3"),
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
ASGI_APPLICATION = "testapp.asgi.application"
|
||||
|
||||
# JWT
|
||||
JWT_PRIVATE_KEY = "integration-test-jwt-secret-key"
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
# Channel layers — Redis when available, in-memory fallback for local dev
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
if REDIS_URL:
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {"hosts": [REDIS_URL]},
|
||||
},
|
||||
}
|
||||
else:
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||
},
|
||||
}
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.db"
|
||||
|
||||
# CORS — allow React dev server
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
]
|
||||
5
example/testapp/urls.py
Normal file
5
example/testapp/urls.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
]
|
||||
Reference in New Issue
Block a user