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:
96
desktop/app.py
Normal file
96
desktop/app.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Djarea Desktop — PyWebView + Django local RPC.
|
||||
|
||||
Starts a local Django ASGI server and opens a native desktop window.
|
||||
All communication between the UI and backend uses Djarea server functions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
|
||||
# Work around Qt WebEngine GPU crashes on some systems
|
||||
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--disable-gpu")
|
||||
|
||||
|
||||
def start_server(host: str, port: int):
|
||||
"""Start the Django ASGI server in a background thread."""
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
# Run migrations on first launch
|
||||
from django.core.management import call_command
|
||||
|
||||
call_command("migrate", "--run-syncdb", verbosity=0)
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"backend.asgi:application",
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="warning",
|
||||
)
|
||||
|
||||
|
||||
def wait_for_server(url: str, timeout: float = 10.0):
|
||||
"""Poll until the server responds."""
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
urlopen(url, timeout=1)
|
||||
return True
|
||||
except (URLError, OSError):
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
host = "127.0.0.1"
|
||||
port = 8765
|
||||
|
||||
# Start Django in a daemon thread
|
||||
server = threading.Thread(target=start_server, args=(host, port), daemon=True)
|
||||
server.start()
|
||||
|
||||
base_url = f"http://{host}:{port}"
|
||||
|
||||
if not wait_for_server(f"{base_url}/api/djarea/session/"):
|
||||
print("ERROR: Django server failed to start", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Backend running at {base_url}")
|
||||
|
||||
# Check if --headless flag is passed (for testing)
|
||||
if "--headless" in sys.argv:
|
||||
print("Headless mode — server running. Press Ctrl+C to stop.")
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return
|
||||
|
||||
# Open native window
|
||||
import webview
|
||||
|
||||
window = webview.create_window(
|
||||
title="Djarea Desktop",
|
||||
url=base_url,
|
||||
width=1024,
|
||||
height=768,
|
||||
min_size=(640, 480),
|
||||
)
|
||||
webview.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
desktop/backend/__init__.py
Normal file
0
desktop/backend/__init__.py
Normal file
6
desktop/backend/apps.py
Normal file
6
desktop/backend/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DesktopBackendConfig(AppConfig):
|
||||
name = "backend"
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
13
desktop/backend/asgi.py
Normal file
13
desktop/backend/asgi.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
|
||||
import django
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
django.setup()
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from djarea import wrap_asgi
|
||||
|
||||
import backend.djarea_clients # noqa: F401
|
||||
|
||||
application = wrap_asgi(get_asgi_application())
|
||||
413
desktop/backend/djarea_clients.py
Normal file
413
desktop/backend/djarea_clients.py
Normal file
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Desktop RPC server functions.
|
||||
|
||||
Tests Djarea's appropriateness for desktop apps:
|
||||
- Local file system access
|
||||
- SQLite CRUD
|
||||
- System introspection
|
||||
- Real-time channels (file watcher, app status)
|
||||
- No auth required (single-user desktop)
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from django.http import HttpRequest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from djarea.client import client
|
||||
from djarea.channels import ReactChannel
|
||||
from djarea.setup.registry import register
|
||||
from djarea.channels import register as register_channel
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System Info
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SystemInfoOutput(BaseModel):
|
||||
os_name: str
|
||||
os_version: str
|
||||
python_version: str
|
||||
hostname: str
|
||||
username: str
|
||||
home_dir: str
|
||||
cwd: str
|
||||
cpu_count: int
|
||||
djarea_version: str
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def system_info(request: HttpRequest) -> SystemInfoOutput:
|
||||
import djarea
|
||||
|
||||
return SystemInfoOutput(
|
||||
os_name=platform.system(),
|
||||
os_version=platform.version(),
|
||||
python_version=sys.version.split()[0],
|
||||
hostname=platform.node(),
|
||||
username=os.getenv("USER", os.getenv("USERNAME", "unknown")),
|
||||
home_dir=str(Path.home()),
|
||||
cwd=os.getcwd(),
|
||||
cpu_count=os.cpu_count() or 1,
|
||||
djarea_version=getattr(djarea, "__version__", "dev"),
|
||||
)
|
||||
|
||||
|
||||
register(system_info, "system_info")
|
||||
|
||||
|
||||
class DiskUsageOutput(BaseModel):
|
||||
path: str
|
||||
total_gb: float
|
||||
used_gb: float
|
||||
free_gb: float
|
||||
percent_used: float
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def disk_usage(request: HttpRequest, path: str = "/") -> DiskUsageOutput:
|
||||
usage = shutil.disk_usage(path)
|
||||
return DiskUsageOutput(
|
||||
path=path,
|
||||
total_gb=round(usage.total / (1024**3), 2),
|
||||
used_gb=round(usage.used / (1024**3), 2),
|
||||
free_gb=round(usage.free / (1024**3), 2),
|
||||
percent_used=round(usage.used / usage.total * 100, 1),
|
||||
)
|
||||
|
||||
|
||||
register(disk_usage, "disk_usage")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# File System
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FileEntry(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
is_dir: bool
|
||||
size: int
|
||||
modified: str
|
||||
|
||||
|
||||
class ListFilesOutput(BaseModel):
|
||||
directory: str
|
||||
entries: list[FileEntry]
|
||||
parent: str | None
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput:
|
||||
dir_path = Path(directory).expanduser().resolve()
|
||||
|
||||
if not dir_path.is_dir():
|
||||
raise ValueError(f"Not a directory: {dir_path}")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
for entry in sorted(dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
entries.append(FileEntry(
|
||||
name=entry.name,
|
||||
path=str(entry),
|
||||
is_dir=entry.is_dir(),
|
||||
size=stat.st_size if not entry.is_dir() else 0,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
))
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
except PermissionError:
|
||||
raise PermissionError(f"Cannot read directory: {dir_path}")
|
||||
|
||||
parent = str(dir_path.parent) if dir_path.parent != dir_path else None
|
||||
|
||||
return ListFilesOutput(
|
||||
directory=str(dir_path),
|
||||
entries=entries,
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
|
||||
register(list_files, "list_files")
|
||||
|
||||
|
||||
class FileContentOutput(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
size: int
|
||||
modified: str
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def read_file(request: HttpRequest, path: str) -> FileContentOutput:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
if not file_path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
stat = file_path.stat()
|
||||
|
||||
# Safety: limit to 1MB text files
|
||||
if stat.st_size > 1_048_576:
|
||||
raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)")
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError(f"Not a text file: {file_path}")
|
||||
|
||||
return FileContentOutput(
|
||||
path=str(file_path),
|
||||
content=content,
|
||||
size=stat.st_size,
|
||||
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
register(read_file, "read_file")
|
||||
|
||||
|
||||
class WriteFileOutput(BaseModel):
|
||||
path: str
|
||||
size: int
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
# Safety: only allow writing within home directory
|
||||
home = Path.home()
|
||||
if not str(file_path).startswith(str(home)):
|
||||
raise PermissionError(f"Can only write files within home directory: {home}")
|
||||
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
return WriteFileOutput(path=str(file_path), size=len(content.encode("utf-8")))
|
||||
|
||||
|
||||
register(write_file, "write_file")
|
||||
|
||||
|
||||
class DeleteFileOutput(BaseModel):
|
||||
path: str
|
||||
deleted: bool
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def delete_file(request: HttpRequest, path: str) -> DeleteFileOutput:
|
||||
file_path = Path(path).expanduser().resolve()
|
||||
|
||||
home = Path.home()
|
||||
if not str(file_path).startswith(str(home)):
|
||||
raise PermissionError(f"Can only delete files within home directory: {home}")
|
||||
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return DeleteFileOutput(path=str(file_path), deleted=True)
|
||||
|
||||
return DeleteFileOutput(path=str(file_path), deleted=False)
|
||||
|
||||
|
||||
register(delete_file, "delete_file")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Notes CRUD (SQLite)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class NoteOutput(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
pinned: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class NoteListOutput(BaseModel):
|
||||
notes: list[NoteOutput]
|
||||
count: int
|
||||
|
||||
|
||||
def _note_to_output(note) -> NoteOutput:
|
||||
return NoteOutput(
|
||||
id=note.id,
|
||||
title=note.title,
|
||||
content=note.content,
|
||||
pinned=note.pinned,
|
||||
created_at=note.created_at.isoformat(),
|
||||
updated_at=note.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def list_notes(request: HttpRequest) -> NoteListOutput:
|
||||
from backend.models import Note
|
||||
|
||||
notes = Note.objects.all()
|
||||
return NoteListOutput(
|
||||
notes=[_note_to_output(n) for n in notes],
|
||||
count=notes.count(),
|
||||
)
|
||||
|
||||
|
||||
register(list_notes, "list_notes")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def create_note(request: HttpRequest, title: str, content: str = "", pinned: bool = False) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
note = Note.objects.create(title=title, content=content, pinned=pinned)
|
||||
return _note_to_output(note)
|
||||
|
||||
|
||||
register(create_note, "create_note")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def get_note(request: HttpRequest, id: int) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
except Note.DoesNotExist:
|
||||
raise ValueError(f"Note {id} not found")
|
||||
|
||||
return _note_to_output(note)
|
||||
|
||||
|
||||
register(get_note, "get_note")
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def update_note(
|
||||
request: HttpRequest,
|
||||
id: int,
|
||||
title: str | None = None,
|
||||
content: str | None = None,
|
||||
pinned: bool | None = None,
|
||||
) -> NoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
except Note.DoesNotExist:
|
||||
raise ValueError(f"Note {id} not found")
|
||||
|
||||
if title is not None:
|
||||
note.title = title
|
||||
if content is not None:
|
||||
note.content = content
|
||||
if pinned is not None:
|
||||
note.pinned = pinned
|
||||
|
||||
note.save()
|
||||
return _note_to_output(note)
|
||||
|
||||
|
||||
register(update_note, "update_note")
|
||||
|
||||
|
||||
class DeleteNoteOutput(BaseModel):
|
||||
id: int
|
||||
deleted: bool
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput:
|
||||
from backend.models import Note
|
||||
|
||||
try:
|
||||
note = Note.objects.get(pk=id)
|
||||
note.delete()
|
||||
return DeleteNoteOutput(id=id, deleted=True)
|
||||
except Note.DoesNotExist:
|
||||
return DeleteNoteOutput(id=id, deleted=False)
|
||||
|
||||
|
||||
register(delete_note, "delete_note")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channels — Real-time Desktop Events
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AppStatusChannel(ReactChannel):
|
||||
"""Push app status updates to the UI (uptime, memory, etc.)."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
uptime_seconds: float
|
||||
memory_mb: float
|
||||
note_count: int
|
||||
timestamp: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True # Desktop app, no auth needed
|
||||
|
||||
def group(self, params=None):
|
||||
return "app_status"
|
||||
|
||||
|
||||
register_channel(AppStatusChannel, "app_status")
|
||||
|
||||
|
||||
class NotesChannel(ReactChannel):
|
||||
"""Push notifications when notes are modified."""
|
||||
|
||||
class DjangoMessage(BaseModel):
|
||||
action: str # "created", "updated", "deleted"
|
||||
note_id: int
|
||||
title: str
|
||||
|
||||
def authorize(self, params=None):
|
||||
return True
|
||||
|
||||
def group(self, params=None):
|
||||
return "notes_updates"
|
||||
|
||||
|
||||
register_channel(NotesChannel, "notes_updates")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# App Lifecycle
|
||||
# =============================================================================
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
|
||||
class AppInfoOutput(BaseModel):
|
||||
app_name: str
|
||||
uptime_seconds: float
|
||||
db_path: str
|
||||
pid: int
|
||||
|
||||
|
||||
@client(websocket=True)
|
||||
def app_info(request: HttpRequest) -> AppInfoOutput:
|
||||
from django.conf import settings
|
||||
|
||||
return AppInfoOutput(
|
||||
app_name="Djarea Desktop",
|
||||
uptime_seconds=round(time.time() - _start_time, 2),
|
||||
db_path=str(settings.DATABASES["default"]["NAME"]),
|
||||
pid=os.getpid(),
|
||||
)
|
||||
|
||||
|
||||
register(app_info, "app_info")
|
||||
15
desktop/backend/models.py
Normal file
15
desktop/backend/models.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
content = models.TextField(blank=True, default="")
|
||||
pinned = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-pinned", "-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
49
desktop/backend/settings.py
Normal file
49
desktop/backend/settings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Django settings for the Djarea desktop integration test app.
|
||||
|
||||
Runs entirely local: SQLite database, in-memory channel layer,
|
||||
no external services required.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
SECRET_KEY = "desktop-app-local-only-secret-key"
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"backend",
|
||||
]
|
||||
|
||||
MIDDLEWARE = []
|
||||
|
||||
ROOT_URLCONF = "backend.urls"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": os.path.join(BASE_DIR, "app.db"),
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
ASGI_APPLICATION = "backend.asgi.application"
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||
},
|
||||
}
|
||||
|
||||
# Serve the built frontend
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_DIRS = [os.path.join(BASE_DIR, "frontend", "dist")]
|
||||
|
||||
# No auth, no CSRF — local desktop app
|
||||
CSRF_COOKIE_HTTPONLY = False
|
||||
34
desktop/backend/urls.py
Normal file
34
desktop/backend/urls.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.urls import include, path, re_path
|
||||
from django.http import HttpResponse, HttpResponseNotFound
|
||||
from pathlib import Path
|
||||
|
||||
DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||
|
||||
CONTENT_TYPES = {
|
||||
".html": "text/html",
|
||||
".js": "application/javascript",
|
||||
".css": "text/css",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".ico": "image/x-icon",
|
||||
".woff2": "font/woff2",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def serve_dist(request, path="index.html"):
|
||||
file_path = (DIST_DIR / path).resolve()
|
||||
|
||||
if not str(file_path).startswith(str(DIST_DIR)) or not file_path.is_file():
|
||||
return HttpResponseNotFound()
|
||||
|
||||
ct = CONTENT_TYPES.get(file_path.suffix, "application/octet-stream")
|
||||
return HttpResponse(file_path.read_bytes(), content_type=ct)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("api/djarea/", include("djarea.urls")),
|
||||
re_path(r"^(?P<path>assets/.+)$", serve_dist),
|
||||
path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
|
||||
path("", serve_dist),
|
||||
]
|
||||
16
desktop/frontend/index.html
Normal file
16
desktop/frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Djarea Desktop</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f0f; color: #e0e0e0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
desktop/frontend/package.json
Normal file
21
desktop/frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "djarea-desktop-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rythazhur/djarea": "file:../../react",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
215
desktop/frontend/src/App.tsx
Normal file
215
desktop/frontend/src/App.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { DjareaProvider, useDjarea, useDjareaStatus } from '@rythazhur/djarea'
|
||||
|
||||
// ─── System Info ────────────────────────────────────────────────────────────
|
||||
|
||||
function SystemInfo() {
|
||||
const { call } = useDjarea()
|
||||
const [info, setInfo] = useState<Record<string, unknown> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
call('system_info').then(setInfo).catch(() => {})
|
||||
}, [call])
|
||||
|
||||
if (!info) return <div style={styles.card}>Loading system info...</div>
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.h2}>System</h2>
|
||||
<table style={styles.table}>
|
||||
<tbody>
|
||||
{Object.entries(info).map(([k, v]) => (
|
||||
<tr key={k}>
|
||||
<td style={styles.label}>{k}</td>
|
||||
<td style={styles.value}>{String(v)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Connection Status ──────────────────────────────────────────────────────
|
||||
|
||||
function StatusBar() {
|
||||
const status = useDjareaStatus()
|
||||
return (
|
||||
<div style={{ ...styles.statusBar, color: status === 'connected' ? '#4ade80' : '#f87171' }}>
|
||||
{status}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Notes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Note = { id: number; title: string; content: string; pinned: boolean; updated_at: string }
|
||||
|
||||
function Notes() {
|
||||
const { call } = useDjarea()
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [selected, setSelected] = useState<Note | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
call<{ notes: Note[] }>('list_notes').then(d => setNotes(d.notes)).catch(() => {})
|
||||
}, [call])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
const create = async () => {
|
||||
if (!title.trim()) return
|
||||
await call('create_note', { title, content })
|
||||
setTitle('')
|
||||
setContent('')
|
||||
refresh()
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return
|
||||
await call('update_note', { id: selected.id, title, content })
|
||||
setSelected(null)
|
||||
setTitle('')
|
||||
setContent('')
|
||||
refresh()
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
await call('delete_note', { id })
|
||||
if (selected?.id === id) { setSelected(null); setTitle(''); setContent('') }
|
||||
refresh()
|
||||
}
|
||||
|
||||
const select = (n: Note) => {
|
||||
setSelected(n)
|
||||
setTitle(n.title)
|
||||
setContent(n.content)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.h2}>Notes ({notes.length})</h2>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
{notes.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => select(n)}
|
||||
style={{
|
||||
...styles.noteItem,
|
||||
borderLeft: selected?.id === n.id ? '3px solid #6cf' : '3px solid transparent',
|
||||
}}
|
||||
>
|
||||
<span>{n.pinned ? '\u{1f4cc} ' : ''}{n.title}</span>
|
||||
<button onClick={e => { e.stopPropagation(); remove(n.id) }} style={styles.deleteBtn}>x</button>
|
||||
</div>
|
||||
))}
|
||||
{notes.length === 0 && <div style={{ color: '#666', padding: 8 }}>No notes yet</div>}
|
||||
</div>
|
||||
<div style={{ flex: 2 }}>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Title"
|
||||
style={styles.input}
|
||||
/>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder="Content"
|
||||
rows={6}
|
||||
style={{ ...styles.input, resize: 'vertical' }}
|
||||
/>
|
||||
<button onClick={selected ? save : create} style={styles.btn}>
|
||||
{selected ? 'Save' : 'Create'}
|
||||
</button>
|
||||
{selected && (
|
||||
<button onClick={() => { setSelected(null); setTitle(''); setContent('') }} style={{ ...styles.btn, background: '#333' }}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── File Browser ───────────────────────────────────────────────────────────
|
||||
|
||||
type FileEntry = { name: string; path: string; is_dir: boolean; size: number }
|
||||
|
||||
function FileBrowser() {
|
||||
const { call } = useDjarea()
|
||||
const [dir, setDir] = useState('~')
|
||||
const [entries, setEntries] = useState<FileEntry[]>([])
|
||||
const [parent, setParent] = useState<string | null>(null)
|
||||
|
||||
const browse = useCallback((d: string) => {
|
||||
call<{ directory: string; entries: FileEntry[]; parent: string | null }>('list_files', { directory: d })
|
||||
.then(data => {
|
||||
setDir(data.directory)
|
||||
setEntries(data.entries.slice(0, 50))
|
||||
setParent(data.parent)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [call])
|
||||
|
||||
useEffect(() => { browse('~') }, [browse])
|
||||
|
||||
return (
|
||||
<div style={styles.card}>
|
||||
<h2 style={styles.h2}>Files</h2>
|
||||
<div style={{ color: '#888', fontSize: 13, marginBottom: 8 }}>{dir}</div>
|
||||
{parent && (
|
||||
<div onClick={() => browse(parent)} style={{ ...styles.fileItem, color: '#6cf', cursor: 'pointer' }}>
|
||||
../ (parent)
|
||||
</div>
|
||||
)}
|
||||
{entries.map(e => (
|
||||
<div
|
||||
key={e.path}
|
||||
onClick={() => e.is_dir && browse(e.path)}
|
||||
style={{ ...styles.fileItem, cursor: e.is_dir ? 'pointer' : 'default', color: e.is_dir ? '#6cf' : '#ccc' }}
|
||||
>
|
||||
{e.is_dir ? '\u{1f4c1}' : '\u{1f4c4}'} {e.name}
|
||||
{!e.is_dir && <span style={{ color: '#666', marginLeft: 8 }}>{(e.size / 1024).toFixed(1)}K</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<DjareaProvider baseUrl="/api/djarea" autoConnect={false}>
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||
<h1 style={{ fontSize: 24, color: '#fff' }}>Djarea Desktop</h1>
|
||||
<StatusBar />
|
||||
</div>
|
||||
<SystemInfo />
|
||||
<Notes />
|
||||
<FileBrowser />
|
||||
</div>
|
||||
</DjareaProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Styles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
card: { background: '#1a1a1a', borderRadius: 8, padding: 20, marginBottom: 16 },
|
||||
h2: { fontSize: 16, marginBottom: 12, color: '#aaa', textTransform: 'uppercase', letterSpacing: 1 },
|
||||
table: { width: '100%', fontSize: 14 },
|
||||
label: { padding: '4px 12px 4px 0', color: '#888', whiteSpace: 'nowrap' },
|
||||
value: { padding: '4px 0', wordBreak: 'break-all' },
|
||||
input: { width: '100%', padding: '8px 12px', marginBottom: 8, background: '#111', border: '1px solid #333', borderRadius: 4, color: '#e0e0e0', fontSize: 14 },
|
||||
btn: { padding: '8px 16px', background: '#2563eb', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', marginRight: 8, fontSize: 14 },
|
||||
noteItem: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', cursor: 'pointer', borderRadius: 4, marginBottom: 2 },
|
||||
deleteBtn: { background: 'none', border: 'none', color: '#666', cursor: 'pointer', fontSize: 14, padding: '2px 6px' },
|
||||
fileItem: { padding: '4px 8px', fontSize: 14 },
|
||||
statusBar: { fontSize: 12, fontFamily: 'monospace' },
|
||||
}
|
||||
4
desktop/frontend/src/main.tsx
Normal file
4
desktop/frontend/src/main.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
11
desktop/frontend/tsconfig.json
Normal file
11
desktop/frontend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
desktop/frontend/vite.config.ts
Normal file
12
desktop/frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8765',
|
||||
'/ws': { target: 'ws://127.0.0.1:8765', ws: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
8
desktop/manage.py
Normal file
8
desktop/manage.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
execute_from_command_line(sys.argv)
|
||||
25
desktop/pyproject.toml
Normal file
25
desktop/pyproject.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "djarea-desktop"
|
||||
version = "0.1.0"
|
||||
description = "Desktop integration test app for Djarea"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"djarea[channels]",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"pywebview[qt]>=5.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
djarea = { path = "../django", editable = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-django>=4.9",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "backend.settings"
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
0
desktop/tests/__init__.py
Normal file
0
desktop/tests/__init__.py
Normal file
7
desktop/tests/conftest.py
Normal file
7
desktop/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import django
|
||||
from django.conf import settings
|
||||
|
||||
# Ensure migrations run before tests
|
||||
def pytest_configure():
|
||||
# Import djarea_clients to trigger function registration
|
||||
import backend.djarea_clients # noqa: F401
|
||||
173
desktop/tests/test_desktop_rpc.py
Normal file
173
desktop/tests/test_desktop_rpc.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
REAL integration tests for the Djarea RPC framework layer.
|
||||
|
||||
Tests the actual HTTP stack: CSRF, middleware, error codes, validation.
|
||||
Every test makes a real HTTP request — no mocks, no RequestFactory.
|
||||
"""
|
||||
|
||||
import json
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from django.test import LiveServerTestCase
|
||||
|
||||
|
||||
class RealHTTPMixin:
|
||||
def _session_init(self):
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
if "csrftoken=" in cookie:
|
||||
self._csrf_token = cookie.split("csrftoken=")[1].split(";")[0]
|
||||
self._cookies = f"csrftoken={self._csrf_token}"
|
||||
return
|
||||
self._csrf_token = None
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if self._csrf_token:
|
||||
req.add_header("X-CSRFToken", self._csrf_token)
|
||||
if self._cookies:
|
||||
req.add_header("Cookie", self._cookies)
|
||||
resp = urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
|
||||
def _raw_post(self, path: str, body: bytes | str, content_type: str = "application/json", include_csrf: bool = False):
|
||||
"""Raw POST without the call() envelope — for testing malformed requests."""
|
||||
url = f"{self.live_server_url}{path}"
|
||||
if isinstance(body, str):
|
||||
body = body.encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", content_type)
|
||||
if include_csrf and self._csrf_token:
|
||||
req.add_header("X-CSRFToken", self._csrf_token)
|
||||
req.add_header("Cookie", self._cookies)
|
||||
return urlopen(req)
|
||||
|
||||
|
||||
class CSRFTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""CSRF handling over real HTTP — the thing that was broken."""
|
||||
|
||||
def test_session_endpoint_sets_csrf_cookie(self):
|
||||
"""GET /session/ must return a Set-Cookie with csrftoken."""
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
|
||||
csrf_cookies = [c for c in cookies if "csrftoken=" in c]
|
||||
self.assertGreater(len(csrf_cookies), 0, "No csrftoken cookie set by /session/")
|
||||
|
||||
def test_call_without_csrf_is_rejected(self):
|
||||
"""POST /call/ without CSRF token must fail."""
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
body = json.dumps({"fn": "system_info", "args": {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
resp = urlopen(req)
|
||||
data = json.loads(resp.read())
|
||||
# If it doesn't raise, the response should indicate an error
|
||||
self.assertTrue(data.get("error"), "POST without CSRF should be rejected")
|
||||
except HTTPError as e:
|
||||
self.assertEqual(e.code, 403, f"Expected 403, got {e.code}")
|
||||
|
||||
def test_call_with_csrf_succeeds(self):
|
||||
"""POST /call/ with valid CSRF token must work."""
|
||||
self._session_init()
|
||||
data = self._call("system_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertIn("os_name", data["data"])
|
||||
|
||||
|
||||
class ValidationTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""Pydantic validation errors over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
|
||||
def test_missing_required_field(self):
|
||||
"""Calling create_note without title should return VALIDATION_ERROR."""
|
||||
data = self._call("create_note", {})
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "VALIDATION_ERROR")
|
||||
|
||||
def test_wrong_type(self):
|
||||
"""Calling delete_note with string id should return VALIDATION_ERROR."""
|
||||
data = self._call("delete_note", {"id": "not-an-int"})
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "VALIDATION_ERROR")
|
||||
|
||||
def test_missing_multiple_fields(self):
|
||||
"""write_file with no args should list all missing fields."""
|
||||
data = self._call("write_file", {})
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "VALIDATION_ERROR")
|
||||
|
||||
|
||||
class ErrorCodeTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""Error codes over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
|
||||
def test_not_found_function(self):
|
||||
data = self._call("this_does_not_exist")
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "NOT_FOUND")
|
||||
|
||||
def test_forbidden_write_outside_home(self):
|
||||
data = self._call("write_file", {"path": "/etc/nope.txt", "content": "x"})
|
||||
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "FORBIDDEN")
|
||||
|
||||
def test_get_method_rejected(self):
|
||||
"""GET to /call/ should be rejected."""
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
try:
|
||||
resp = urlopen(Request(url))
|
||||
data = json.loads(resp.read())
|
||||
self.assertTrue(data.get("error"))
|
||||
except HTTPError as e:
|
||||
self.assertIn(e.code, [403, 405])
|
||||
|
||||
def test_invalid_json_body(self):
|
||||
"""Malformed JSON should return BAD_REQUEST."""
|
||||
self._session_init()
|
||||
try:
|
||||
resp = self._raw_post(
|
||||
"/api/djarea/call/",
|
||||
body="not valid json{{{",
|
||||
include_csrf=True,
|
||||
)
|
||||
data = json.loads(resp.read())
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "BAD_REQUEST")
|
||||
except HTTPError as e:
|
||||
self.assertIn(e.code, [400, 403])
|
||||
|
||||
def test_missing_fn_field(self):
|
||||
"""POST with valid JSON but no 'fn' field should return BAD_REQUEST."""
|
||||
self._session_init()
|
||||
try:
|
||||
resp = self._raw_post(
|
||||
"/api/djarea/call/",
|
||||
body=json.dumps({"not_fn": "hello"}),
|
||||
include_csrf=True,
|
||||
)
|
||||
data = json.loads(resp.read())
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "BAD_REQUEST")
|
||||
except HTTPError as e:
|
||||
self.assertIn(e.code, [400, 403])
|
||||
142
desktop/tests/test_notes.py
Normal file
142
desktop/tests/test_notes.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
REAL integration tests for notes CRUD over HTTP.
|
||||
|
||||
Every test makes actual HTTP requests to a live Django server.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.test import LiveServerTestCase
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
|
||||
class RealHTTPMixin:
|
||||
def _session_init(self):
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
resp = urlopen(Request(url))
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
if "csrftoken=" in cookie:
|
||||
self._csrf_token = cookie.split("csrftoken=")[1].split(";")[0]
|
||||
self._cookies = f"csrftoken={self._csrf_token}"
|
||||
return
|
||||
self._csrf_token = None
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if self._csrf_token:
|
||||
req.add_header("X-CSRFToken", self._csrf_token)
|
||||
if self._cookies:
|
||||
req.add_header("Cookie", self._cookies)
|
||||
resp = urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
class NotesCRUDTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""Full CRUD lifecycle over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
|
||||
def test_list_notes_empty(self):
|
||||
data = self._call("list_notes")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["notes"], [])
|
||||
self.assertEqual(data["data"]["count"], 0)
|
||||
|
||||
def test_create_note(self):
|
||||
data = self._call("create_note", {"title": "First Note", "content": "Hello!"})
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["title"], "First Note")
|
||||
self.assertEqual(data["data"]["content"], "Hello!")
|
||||
self.assertFalse(data["data"]["pinned"])
|
||||
self.assertIn("id", data["data"])
|
||||
self.assertIn("created_at", data["data"])
|
||||
|
||||
def test_create_and_list(self):
|
||||
self._call("create_note", {"title": "Note A"})
|
||||
self._call("create_note", {"title": "Note B"})
|
||||
|
||||
data = self._call("list_notes")
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["count"], 2)
|
||||
titles = [n["title"] for n in data["data"]["notes"]]
|
||||
self.assertIn("Note A", titles)
|
||||
self.assertIn("Note B", titles)
|
||||
|
||||
def test_get_note_by_id(self):
|
||||
create = self._call("create_note", {"title": "Get Me", "content": "Specific"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
data = self._call("get_note", {"id": note_id})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["id"], note_id)
|
||||
self.assertEqual(data["data"]["title"], "Get Me")
|
||||
|
||||
def test_update_note(self):
|
||||
create = self._call("create_note", {"title": "Original"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
data = self._call("update_note", {"id": note_id, "title": "Updated"})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["title"], "Updated")
|
||||
|
||||
def test_update_note_pin(self):
|
||||
create = self._call("create_note", {"title": "Pin Me"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
data = self._call("update_note", {"id": note_id, "pinned": True})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertTrue(data["data"]["pinned"])
|
||||
|
||||
def test_delete_note(self):
|
||||
create = self._call("create_note", {"title": "Delete Me"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
data = self._call("delete_note", {"id": note_id})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertTrue(data["data"]["deleted"])
|
||||
|
||||
# Verify it's gone
|
||||
from urllib.error import HTTPError
|
||||
try:
|
||||
get_data = self._call("get_note", {"id": note_id})
|
||||
self.assertTrue(get_data["error"])
|
||||
except HTTPError:
|
||||
pass # 500 is also a valid failure signal
|
||||
|
||||
def test_pinned_notes_sort_first(self):
|
||||
self._call("create_note", {"title": "Unpinned"})
|
||||
self._call("create_note", {"title": "Pinned", "pinned": True})
|
||||
|
||||
data = self._call("list_notes")
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["notes"][0]["title"], "Pinned")
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
"""Create -> update -> pin -> verify -> delete over real HTTP."""
|
||||
# Create
|
||||
create = self._call("create_note", {"title": "Lifecycle", "content": "v1"})
|
||||
note_id = create["data"]["id"]
|
||||
|
||||
# Update
|
||||
self._call("update_note", {"id": note_id, "content": "v2"})
|
||||
|
||||
# Pin
|
||||
self._call("update_note", {"id": note_id, "pinned": True})
|
||||
|
||||
# Verify
|
||||
get = self._call("get_note", {"id": note_id})
|
||||
self.assertEqual(get["data"]["title"], "Lifecycle")
|
||||
self.assertEqual(get["data"]["content"], "v2")
|
||||
self.assertTrue(get["data"]["pinned"])
|
||||
|
||||
# Delete
|
||||
delete = self._call("delete_note", {"id": note_id})
|
||||
self.assertTrue(delete["data"]["deleted"])
|
||||
162
desktop/tests/test_system.py
Normal file
162
desktop/tests/test_system.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
REAL integration tests for desktop system RPC functions.
|
||||
|
||||
These make actual HTTP requests to a running Django server.
|
||||
No RequestFactory, no mocks, no shortcuts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from django.test import LiveServerTestCase
|
||||
from urllib.request import urlopen, Request
|
||||
|
||||
|
||||
class RealHTTPMixin:
|
||||
"""Makes real HTTP requests to the live server."""
|
||||
|
||||
def _session_init(self):
|
||||
"""Hit /session/ to get CSRF cookie, like DjareaProvider does."""
|
||||
url = f"{self.live_server_url}/api/djarea/session/"
|
||||
req = Request(url)
|
||||
resp = urlopen(req)
|
||||
# Extract csrftoken from Set-Cookie header
|
||||
cookies = resp.headers.get_all("Set-Cookie") or []
|
||||
for cookie in cookies:
|
||||
if "csrftoken=" in cookie:
|
||||
self._csrf_token = cookie.split("csrftoken=")[1].split(";")[0]
|
||||
self._cookies = f"csrftoken={self._csrf_token}"
|
||||
return
|
||||
self._csrf_token = None
|
||||
self._cookies = ""
|
||||
|
||||
def _call(self, fn: str, args: dict | None = None):
|
||||
"""Make a real POST to /api/djarea/call/ with CSRF token."""
|
||||
url = f"{self.live_server_url}/api/djarea/call/"
|
||||
body = json.dumps({"fn": fn, "args": args or {}}).encode()
|
||||
req = Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if self._csrf_token:
|
||||
req.add_header("X-CSRFToken", self._csrf_token)
|
||||
if self._cookies:
|
||||
req.add_header("Cookie", self._cookies)
|
||||
resp = urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
class SystemInfoTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""system_info over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
|
||||
def test_system_info_returns_os_data(self):
|
||||
data = self._call("system_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["os_name"], platform.system())
|
||||
self.assertEqual(data["data"]["hostname"], platform.node())
|
||||
self.assertGreater(data["data"]["cpu_count"], 0)
|
||||
|
||||
def test_system_info_returns_paths(self):
|
||||
data = self._call("system_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["home_dir"], str(Path.home()))
|
||||
self.assertEqual(data["data"]["cwd"], os.getcwd())
|
||||
|
||||
def test_disk_usage(self):
|
||||
data = self._call("disk_usage", {"path": "/"})
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertGreater(data["data"]["total_gb"], 0)
|
||||
self.assertGreater(data["data"]["free_gb"], 0)
|
||||
self.assertGreaterEqual(data["data"]["percent_used"], 0)
|
||||
self.assertLessEqual(data["data"]["percent_used"], 100)
|
||||
|
||||
def test_app_info(self):
|
||||
data = self._call("app_info")
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["app_name"], "Djarea Desktop")
|
||||
self.assertGreater(data["data"]["uptime_seconds"], 0)
|
||||
|
||||
|
||||
class FileSystemTests(RealHTTPMixin, LiveServerTestCase):
|
||||
"""File system RPC over real HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
self._session_init()
|
||||
self.test_dir = Path.home() / ".djarea-test"
|
||||
self.test_dir.mkdir(exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
import shutil
|
||||
if self.test_dir.exists():
|
||||
shutil.rmtree(self.test_dir)
|
||||
|
||||
def test_list_files_home(self):
|
||||
data = self._call("list_files", {"directory": "~"})
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertEqual(data["data"]["directory"], str(Path.home()))
|
||||
self.assertIsInstance(data["data"]["entries"], list)
|
||||
|
||||
def test_list_files_root_has_no_parent(self):
|
||||
data = self._call("list_files", {"directory": "/"})
|
||||
|
||||
self.assertFalse(data["error"])
|
||||
self.assertIsNone(data["data"]["parent"])
|
||||
|
||||
def test_write_and_read_file(self):
|
||||
"""Full round-trip over real HTTP: write, read back, verify."""
|
||||
test_path = str(self.test_dir / "test-note.txt")
|
||||
test_content = "Hello from a REAL HTTP integration test!"
|
||||
|
||||
# Write
|
||||
write_data = self._call("write_file", {"path": test_path, "content": test_content})
|
||||
self.assertFalse(write_data["error"])
|
||||
self.assertEqual(write_data["data"]["path"], test_path)
|
||||
|
||||
# Read back
|
||||
read_data = self._call("read_file", {"path": test_path})
|
||||
self.assertFalse(read_data["error"])
|
||||
self.assertEqual(read_data["data"]["content"], test_content)
|
||||
|
||||
def test_write_outside_home_rejected(self):
|
||||
"""Server should reject writes outside home directory."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
try:
|
||||
data = self._call("write_file", {"path": "/tmp/escape.txt", "content": "nope"})
|
||||
# If we get here, check the response has an error
|
||||
self.assertTrue(data["error"])
|
||||
self.assertEqual(data["code"], "FORBIDDEN")
|
||||
except HTTPError as e:
|
||||
# 403 is also acceptable
|
||||
self.assertEqual(e.code, 403)
|
||||
|
||||
def test_delete_file(self):
|
||||
test_path = str(self.test_dir / "to-delete.txt")
|
||||
(self.test_dir / "to-delete.txt").write_text("delete me")
|
||||
|
||||
data = self._call("delete_file", {"path": test_path})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertTrue(data["data"]["deleted"])
|
||||
self.assertFalse(Path(test_path).exists())
|
||||
|
||||
def test_file_entries_have_metadata(self):
|
||||
(self.test_dir / "metadata-test.txt").write_text("hello")
|
||||
|
||||
data = self._call("list_files", {"directory": str(self.test_dir)})
|
||||
self.assertFalse(data["error"])
|
||||
self.assertGreater(len(data["data"]["entries"]), 0)
|
||||
|
||||
entry = data["data"]["entries"][0]
|
||||
self.assertIn("name", entry)
|
||||
self.assertIn("path", entry)
|
||||
self.assertIn("is_dir", entry)
|
||||
self.assertIn("size", entry)
|
||||
self.assertIn("modified", entry)
|
||||
Reference in New Issue
Block a user