Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c26d0ead | |||
| 3f3e3a6ae5 | |||
| 09d012d065 | |||
| b98e52378e | |||
| f39205dba0 |
@@ -22,6 +22,7 @@ tools.db
|
||||
.idea/sqldialects.xml
|
||||
.idea_bak
|
||||
**/*.prof
|
||||
**/*.db
|
||||
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Python template
|
||||
|
||||
@@ -14,5 +14,14 @@ clean-build: clean-package
|
||||
find . -name "*.pyc" -exec rm -f {} +
|
||||
find . -name "*.pyo" -exec rm -f {} +
|
||||
|
||||
clean-tests:
|
||||
rm -rf .sesskey
|
||||
rm -rf tests/.sesskey
|
||||
rm -rf tests/*.db
|
||||
|
||||
# Alias to clean everything
|
||||
clean: clean-build
|
||||
clean: clean-build clean-tests
|
||||
|
||||
clean-all : clean
|
||||
rm -rf src/.sesskey
|
||||
rm -rf src/Users.db
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
annotated-doc==0.0.3
|
||||
annotated-types==0.7.0
|
||||
anyio==4.11.0
|
||||
apsw==3.50.4.0
|
||||
apswutils==0.1.0
|
||||
argon2-cffi==25.1.0
|
||||
argon2-cffi-bindings==25.1.0
|
||||
beautifulsoup4==4.14.2
|
||||
certifi==2025.10.5
|
||||
cffi==2.0.0
|
||||
click==8.3.0
|
||||
cryptography==46.0.3
|
||||
dnspython==2.8.0
|
||||
ecdsa==0.19.1
|
||||
email-validator==2.3.0
|
||||
fastapi==0.120.0
|
||||
fastcore==1.8.13
|
||||
fastlite==0.2.1
|
||||
h11==0.16.0
|
||||
@@ -13,20 +23,30 @@ httpx==0.28.1
|
||||
idna==3.11
|
||||
iniconfig==2.3.0
|
||||
itsdangerous==2.2.0
|
||||
-e git+ssh://git@sheerka.synology.me:1010/kodjo/MyAuth.git@0138ac247a4a53dc555b94ec13119eba16e1db68#egg=myauth
|
||||
oauthlib==3.3.1
|
||||
packaging==25.0
|
||||
passlib==1.7.4
|
||||
pluggy==1.6.0
|
||||
pyasn1==0.6.1
|
||||
pycparser==2.23
|
||||
pydantic==2.12.3
|
||||
pydantic-settings==2.11.0
|
||||
pydantic_core==2.41.4
|
||||
Pygments==2.19.2
|
||||
pytest==8.4.2
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.1.1
|
||||
python-fasthtml==0.12.30
|
||||
python-jose==3.5.0
|
||||
python-multipart==0.0.20
|
||||
PyYAML==6.0.3
|
||||
rsa==4.9.1
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
soupsieve==2.8
|
||||
starlette==0.48.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
uvicorn==0.38.0
|
||||
uvloop==0.22.1
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import Link, Script
|
||||
from fasthtml.fastapp import fast_app
|
||||
from myauth import create_auth_app_for_sqlite
|
||||
|
||||
from myfasthtml.auth.routes import setup_auth_routes
|
||||
from myfasthtml.auth.utils import create_auth_beforeware
|
||||
|
||||
beforeware = create_auth_beforeware()
|
||||
# Create FastHTML app
|
||||
daisyui_offline_links = [
|
||||
Link(href="./myfasthtml/assets/daisyui-5.css", rel="stylesheet", type="text/css"),
|
||||
Link(href="./myfasthtml/assets/daisyui-5-themes.css", rel="stylesheet", type="text/css"),
|
||||
Script(src="./myfasthtml/assets/tailwindcss-browser@4.js"),
|
||||
]
|
||||
|
||||
daisyui_online_links = [
|
||||
Link(rel='stylesheet', href='https://cdn.jsdelivr.net/npm/daisyui@5/daisyui.css'),
|
||||
Script(src='https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'),
|
||||
]
|
||||
|
||||
app, rt = fast_app(
|
||||
before=beforeware,
|
||||
hdrs=tuple(daisyui_offline_links)
|
||||
)
|
||||
|
||||
# Setup authentication routes
|
||||
setup_auth_routes(app, rt)
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,283 @@
|
||||
# FastHTML + FastAPI Authentication Integration
|
||||
|
||||
This package provides a complete authentication system integrating FastHTML (frontend) with FastAPI (backend).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
auth/
|
||||
├── __init__.py # Package initialization
|
||||
├── utils.py # JWT helpers + Beforeware
|
||||
├── pages/
|
||||
│ ├── __init__.py
|
||||
│ ├── LoginPage.py # Login form component
|
||||
│ ├── RegisterPage.py # Registration form component
|
||||
│ └── WelcomePage.py # Protected welcome page
|
||||
└── routes.py # All routes (public + protected)
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Login page** with email/password authentication
|
||||
- ✅ **Registration page** with password confirmation
|
||||
- ✅ **Automatic token refresh** (proactive refresh at 5 minutes before expiry)
|
||||
- ✅ **Route protection** with Beforeware (whitelist support)
|
||||
- ✅ **Session management** using FastHTML signed cookies
|
||||
- ✅ **DaisyUI 5** components for beautiful UI
|
||||
- ✅ **HTMX integration** for dynamic form submissions
|
||||
|
||||
## Dependencies
|
||||
|
||||
Install the required packages:
|
||||
|
||||
```bash
|
||||
pip install fasthtml httpx python-jose[cryptography] --break-system-packages
|
||||
```
|
||||
|
||||
Note: `python-jose` may already be installed if you have FastAPI.
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Update API configuration in `auth/utils.py`
|
||||
|
||||
```python
|
||||
API_BASE_URL = "http://localhost:5001" # Your FastAPI backend URL
|
||||
JWT_SECRET = "jwt-secret-to-change" # Must match your FastAPI secret
|
||||
```
|
||||
|
||||
### 2. Create your `main.py`
|
||||
|
||||
```python
|
||||
from fasthtml.common import fast_app, serve, Link, Script
|
||||
from auth import create_auth_beforeware, setup_auth_routes
|
||||
|
||||
# Import your existing FastAPI auth app
|
||||
# from your_auth_module import create_auth_app_for_sqlite
|
||||
|
||||
# Create Beforeware for route protection
|
||||
beforeware = create_auth_beforeware()
|
||||
|
||||
# Create FastHTML app
|
||||
app, rt = fast_app(
|
||||
before=beforeware,
|
||||
hdrs=(
|
||||
# DaisyUI 5 CSS
|
||||
Link(rel='stylesheet', href='https://cdn.jsdelivr.net/npm/daisyui@5/daisyui.css'),
|
||||
# Tailwind CSS 4
|
||||
Script(src='https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4'),
|
||||
)
|
||||
)
|
||||
|
||||
# Mount FastAPI auth backend
|
||||
# auth_api = create_auth_app_for_sqlite("Users.db", "jwt-secret-to-change")
|
||||
# app.mount("/auth", auth_api)
|
||||
|
||||
# Setup authentication routes
|
||||
setup_auth_routes(app, rt)
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting the application
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:5001`.
|
||||
|
||||
### Routes
|
||||
|
||||
**Public routes:**
|
||||
- `GET /login` - Display login page
|
||||
- `POST /login` - Handle login submission
|
||||
- `GET /register` - Display registration page
|
||||
- `POST /register` - Handle registration submission
|
||||
|
||||
**Protected routes (require authentication):**
|
||||
- `GET /` - Welcome page (home)
|
||||
- `GET /welcome` - Alternative welcome page
|
||||
- `POST /logout` - Logout and revoke tokens
|
||||
|
||||
### Adding custom protected routes
|
||||
|
||||
```python
|
||||
@rt("/dashboard")
|
||||
def get_dashboard(session, auth):
|
||||
"""
|
||||
Custom protected route.
|
||||
|
||||
The 'auth' parameter is automatically injected by the Beforeware.
|
||||
"""
|
||||
user_info = session.get('user_info', {})
|
||||
return Div(
|
||||
H1(f"Dashboard for {user_info.get('email', 'User')}"),
|
||||
# Your dashboard content
|
||||
)
|
||||
```
|
||||
|
||||
### Extending the whitelist
|
||||
|
||||
If you need to add more public routes:
|
||||
|
||||
```python
|
||||
beforeware = create_auth_beforeware(
|
||||
additional_patterns=[
|
||||
'/about',
|
||||
'/contact',
|
||||
r'/api/.*', # Regex patterns are supported
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
### 1. Beforeware Protection
|
||||
|
||||
The `auth_before()` function runs before every route (except whitelisted ones):
|
||||
|
||||
1. Checks if `access_token` exists in session
|
||||
2. Validates token expiration
|
||||
3. If token expires in < 5 minutes, automatically refreshes it
|
||||
4. If refresh fails or no token exists, redirects to `/login`
|
||||
|
||||
### 2. Token Refresh Flow
|
||||
|
||||
```
|
||||
User Request → Beforeware checks token
|
||||
↓
|
||||
Token expires in < 5 min?
|
||||
↓ Yes
|
||||
Call /auth/refresh with refresh_token
|
||||
↓
|
||||
Update session with new tokens
|
||||
↓
|
||||
Continue to route handler
|
||||
```
|
||||
|
||||
### 3. Session Storage
|
||||
|
||||
Session contains:
|
||||
- `access_token` (JWT, 30 minutes validity)
|
||||
- `refresh_token` (Opaque, 7 days validity)
|
||||
- `user_info` (email, id, etc.)
|
||||
|
||||
## Customization
|
||||
|
||||
### Changing the refresh threshold
|
||||
|
||||
Edit `auth/utils.py`:
|
||||
|
||||
```python
|
||||
TOKEN_REFRESH_THRESHOLD_MINUTES = 5 # Change to your preferred value
|
||||
```
|
||||
|
||||
### Customizing page components
|
||||
|
||||
Each page component is a class with:
|
||||
- `__init__()` - Constructor with parameters
|
||||
- `_render()` - HTML generation logic
|
||||
- `__ft__()` - FastHTML protocol method
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
class LoginPage:
|
||||
def __init__(self, error_message=None, redirect_url="/"):
|
||||
self.error_message = error_message
|
||||
self.redirect_url = redirect_url
|
||||
|
||||
def _render(self):
|
||||
# Build your custom HTML here
|
||||
return Div(...)
|
||||
|
||||
def __ft__(self):
|
||||
return self._render()
|
||||
```
|
||||
|
||||
### Styling with DaisyUI 5
|
||||
|
||||
DaisyUI 5 provides semantic class names:
|
||||
|
||||
```python
|
||||
# Button
|
||||
Button("Click me", cls="btn btn-primary")
|
||||
|
||||
# Input
|
||||
Input(type="email", cls="input input-bordered")
|
||||
|
||||
# Card
|
||||
Div(
|
||||
Div(
|
||||
H2("Title", cls="card-title"),
|
||||
P("Content"),
|
||||
cls="card-body"
|
||||
),
|
||||
cls="card bg-base-100 shadow-xl"
|
||||
)
|
||||
|
||||
# Alert
|
||||
Div(
|
||||
Span("Success message"),
|
||||
cls="alert alert-success"
|
||||
)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "Module not found: auth"
|
||||
|
||||
Make sure the `auth` directory is in your Python path or in the same directory as `main.py`.
|
||||
|
||||
### Issue: "Connection refused" when calling API
|
||||
|
||||
Check that:
|
||||
1. `API_BASE_URL` in `auth/utils.py` matches your FastAPI server
|
||||
2. Your FastAPI backend is running
|
||||
3. The `/auth` endpoints are correctly mounted
|
||||
|
||||
### Issue: Token refresh not working
|
||||
|
||||
Verify:
|
||||
1. `JWT_SECRET` in `auth/utils.py` matches your FastAPI secret
|
||||
2. Your FastAPI `/auth/refresh` endpoint is working
|
||||
3. The refresh token is being stored correctly in the session
|
||||
|
||||
### Issue: Infinite redirect loop
|
||||
|
||||
Check:
|
||||
1. The `/login` route is in the whitelist (it should be by default)
|
||||
2. Your FastAPI `/auth/login` endpoint returns `access_token` and `refresh_token`
|
||||
|
||||
## Testing
|
||||
|
||||
To test the authentication flow:
|
||||
|
||||
1. Start your application
|
||||
2. Navigate to `http://localhost:5001/`
|
||||
3. You should be redirected to `/login`
|
||||
4. Register a new account at `/register`
|
||||
5. Login with your credentials
|
||||
6. You should see the welcome page
|
||||
7. Your session will automatically refresh tokens as needed
|
||||
|
||||
## API Integration
|
||||
|
||||
This package expects your FastAPI backend to provide these endpoints:
|
||||
|
||||
- `POST /auth/login` - Returns `access_token` and `refresh_token`
|
||||
- `POST /auth/register` - Creates a new user
|
||||
- `POST /auth/refresh` - Refreshes the access token
|
||||
- `GET /auth/me` - Returns current user info (requires Bearer token)
|
||||
- `POST /auth/logout` - Revokes refresh token
|
||||
|
||||
## License
|
||||
|
||||
This code is provided as-is for educational and development purposes.
|
||||
|
||||
## Questions?
|
||||
|
||||
Feel free to modify and extend this authentication system according to your needs!
|
||||
@@ -2,8 +2,7 @@ from fasthtml.components import *
|
||||
|
||||
|
||||
class LoginPage:
|
||||
def __init__(self, settings_manager, error_message=None, success_message=None):
|
||||
self.settings_manager = settings_manager
|
||||
def __init__(self, error_message=None, success_message=None):
|
||||
self.error_message = error_message
|
||||
self.success_message = success_message
|
||||
|
||||
@@ -66,7 +65,7 @@ class LoginPage:
|
||||
cls="btn w-full font-bold py-2 px-4 rounded"
|
||||
),
|
||||
|
||||
action=ROUTE_ROOT + Routes.LoginByEmail,
|
||||
action="/login-p",
|
||||
method="post",
|
||||
cls="mb-6"
|
||||
),
|
||||
@@ -84,3 +83,6 @@ class LoginPage:
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -0,0 +1,125 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
|
||||
class RegisterPage:
|
||||
def __init__(self, error_message: str = None):
|
||||
self.error_message = error_message
|
||||
|
||||
def register_page(self, error_message: str):
|
||||
self.error_message = error_message
|
||||
return self.__ft__()
|
||||
|
||||
def __ft__(self):
|
||||
"""
|
||||
Create the registration page.
|
||||
|
||||
Args:
|
||||
error_message: Optional error message to display
|
||||
|
||||
Returns:
|
||||
Components representing the registration page
|
||||
"""
|
||||
# Create alert for error message
|
||||
error_alert = None
|
||||
if self.error_message:
|
||||
error_alert = Div(
|
||||
P(self.error_message, cls="text-sm"),
|
||||
cls="bg-soft bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
|
||||
)
|
||||
|
||||
return Div(
|
||||
# Page title
|
||||
H1("Create an Account", cls="text-3xl font-bold text-center mb-6"),
|
||||
|
||||
# Registration Form
|
||||
Div(
|
||||
# Error alert
|
||||
error_alert if error_alert else "",
|
||||
|
||||
Form(
|
||||
# Username field
|
||||
Div(
|
||||
Label("Username", For="username", cls="block text-sm font-medium text-gray-700 mb-1"),
|
||||
Input(
|
||||
type="text",
|
||||
id="username",
|
||||
name="username",
|
||||
placeholder="Choose a username",
|
||||
required=True,
|
||||
minlength=3,
|
||||
maxlength=30,
|
||||
pattern="[a-zA-Z0-9_-]+",
|
||||
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
|
||||
),
|
||||
P("Only letters, numbers, underscores, and hyphens", cls="text-xs text-gray-500 mt-1"),
|
||||
cls="mb-4"
|
||||
),
|
||||
|
||||
# Email field
|
||||
Div(
|
||||
Label("Email", For="email", cls="block text-sm font-medium text-gray-700 mb-1"),
|
||||
Input(
|
||||
type="email",
|
||||
id="email",
|
||||
name="email",
|
||||
placeholder="you@example.com",
|
||||
required=True,
|
||||
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
|
||||
),
|
||||
cls="mb-4"
|
||||
),
|
||||
|
||||
# Password field
|
||||
Div(
|
||||
Label("Password", For="password", cls="block text-sm font-medium text-gray-700 mb-1"),
|
||||
Input(
|
||||
type="password",
|
||||
id="password",
|
||||
name="password",
|
||||
placeholder="Create a password",
|
||||
required=True,
|
||||
minlength=8,
|
||||
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
|
||||
),
|
||||
P("At least 8 characters with uppercase, lowercase, and number", cls="text-xs text-gray-500 mt-1"),
|
||||
cls="mb-4"
|
||||
),
|
||||
|
||||
# Confirm password field
|
||||
Div(
|
||||
Label("Confirm Password", For="confirm_password", cls="block text-sm font-medium text-gray-700 mb-1"),
|
||||
Input(
|
||||
type="password",
|
||||
id="confirm_password",
|
||||
name="confirm_password",
|
||||
placeholder="Confirm your password",
|
||||
required=True,
|
||||
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
|
||||
),
|
||||
cls="mb-6"
|
||||
),
|
||||
|
||||
# Submit button
|
||||
Button(
|
||||
"Create Account",
|
||||
type="submit",
|
||||
cls="btn w-full font-bold py-2 px-4 rounded"
|
||||
),
|
||||
|
||||
# Registration link
|
||||
Div(
|
||||
P(
|
||||
"Already have an account? ",
|
||||
A("Sign in here", href="/login", cls="text-blue-600 hover:underline"),
|
||||
cls="text-sm text-gray-600 text-center"
|
||||
)
|
||||
),
|
||||
|
||||
action="register-p",
|
||||
method="post",
|
||||
cls="mb-6"
|
||||
),
|
||||
|
||||
cls="p-8 rounded-lg shadow-2xl max-w-md mx-auto"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Register page component for FastHTML application.
|
||||
|
||||
Provides a styled registration form using DaisyUI 5 components.
|
||||
"""
|
||||
|
||||
from fasthtml.common import Div, Form, Input, Button, H1, P, A, Label
|
||||
|
||||
|
||||
class RegisterPage:
|
||||
"""
|
||||
Register page component with email and password fields.
|
||||
|
||||
Attributes:
|
||||
error_message: Optional error message to display
|
||||
redirect_url: URL to redirect after successful registration
|
||||
"""
|
||||
|
||||
def __init__(self, error_message: str = None, redirect_url: str = "/login"):
|
||||
"""
|
||||
Initialize register page.
|
||||
|
||||
Args:
|
||||
error_message: Optional error message to display
|
||||
redirect_url: URL to redirect after successful registration (default: "/login")
|
||||
"""
|
||||
self.error_message = error_message
|
||||
self.redirect_url = redirect_url
|
||||
|
||||
def _render(self):
|
||||
"""
|
||||
Render the register page HTML structure.
|
||||
|
||||
Returns:
|
||||
FastHTML component tree for the register page
|
||||
"""
|
||||
# Error alert component (only shown if error_message exists)
|
||||
error_alert = None
|
||||
if self.error_message:
|
||||
error_alert = Div(
|
||||
Div(
|
||||
self._svg_icon(),
|
||||
self.error_message,
|
||||
cls="flex items-center gap-2"
|
||||
),
|
||||
role="alert",
|
||||
cls="alert alert-error mb-4",
|
||||
id="error-alert"
|
||||
)
|
||||
|
||||
# Success message container (empty by default, filled by HTMX on success)
|
||||
success_alert = Div(id="success-alert")
|
||||
|
||||
# Register form
|
||||
register_form = Form(
|
||||
# Hidden field for redirect URL
|
||||
Input(type="hidden", name="redirect_url", value=self.redirect_url),
|
||||
|
||||
# Email input
|
||||
Label(
|
||||
Div("Email", cls="label-text"),
|
||||
cls="form-control w-full mb-4"
|
||||
)(
|
||||
Input(
|
||||
type="email",
|
||||
name="email",
|
||||
placeholder="[email protected]",
|
||||
required=True,
|
||||
cls="input input-bordered w-full"
|
||||
)
|
||||
),
|
||||
|
||||
# Password input
|
||||
Label(
|
||||
Div("Password", cls="label-text"),
|
||||
Div("Must be at least 8 characters", cls="label-text-alt text-base-content/60"),
|
||||
cls="form-control w-full mb-4"
|
||||
)(
|
||||
Input(
|
||||
type="password",
|
||||
name="password",
|
||||
placeholder="Create a password",
|
||||
required=True,
|
||||
minlength="8",
|
||||
cls="input input-bordered w-full"
|
||||
)
|
||||
),
|
||||
|
||||
# Confirm password input
|
||||
Label(
|
||||
Div("Confirm Password", cls="label-text"),
|
||||
cls="form-control w-full mb-4"
|
||||
)(
|
||||
Input(
|
||||
type="password",
|
||||
name="confirm_password",
|
||||
placeholder="Confirm your password",
|
||||
required=True,
|
||||
minlength="8",
|
||||
cls="input input-bordered w-full"
|
||||
)
|
||||
),
|
||||
|
||||
# Submit button
|
||||
Button(
|
||||
"Create Account",
|
||||
type="submit",
|
||||
cls="btn btn-primary w-full"
|
||||
),
|
||||
|
||||
method="post",
|
||||
action="/register",
|
||||
hx_post="/register",
|
||||
hx_target="#register-card",
|
||||
hx_swap="outerHTML"
|
||||
)
|
||||
|
||||
# Main card container
|
||||
card = Div(
|
||||
Div(
|
||||
# Card title
|
||||
H1("Create Account", cls="text-3xl font-bold text-center mb-2"),
|
||||
P("Sign up for a new account", cls="text-center text-base-content/70 mb-6"),
|
||||
|
||||
# Success alert
|
||||
success_alert,
|
||||
|
||||
# Error alert
|
||||
error_alert if error_alert else Div(),
|
||||
|
||||
# Register form
|
||||
register_form,
|
||||
|
||||
# Login link
|
||||
Div(
|
||||
P(
|
||||
"Already have an account? ",
|
||||
A("Sign in", href="/login", cls="link link-primary"),
|
||||
cls="text-center text-sm"
|
||||
),
|
||||
cls="mt-4"
|
||||
),
|
||||
|
||||
cls="card-body"
|
||||
),
|
||||
cls="card bg-base-100 w-96 shadow-xl",
|
||||
id="register-card"
|
||||
)
|
||||
|
||||
# Full page container
|
||||
return Div(
|
||||
card,
|
||||
cls="flex items-center justify-center min-h-screen bg-base-200"
|
||||
)
|
||||
|
||||
def _svg_icon(self):
|
||||
"""
|
||||
Helper method to create error icon SVG.
|
||||
|
||||
Returns:
|
||||
SVG icon as NotStr
|
||||
"""
|
||||
from fasthtml.common import NotStr
|
||||
return NotStr('''
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
''')
|
||||
|
||||
def __ft__(self):
|
||||
"""
|
||||
FastHTML rendering protocol method.
|
||||
|
||||
Returns:
|
||||
Rendered register page component
|
||||
"""
|
||||
return self._render()
|
||||
|
||||
|
||||
def create_success_message(message: str = "Account created successfully!"):
|
||||
"""
|
||||
Create a success alert component for HTMX response.
|
||||
|
||||
Args:
|
||||
message: Success message to display
|
||||
|
||||
Returns:
|
||||
Success alert component
|
||||
"""
|
||||
from fasthtml.common import NotStr
|
||||
|
||||
return Div(
|
||||
Div(
|
||||
NotStr('''
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
'''),
|
||||
message,
|
||||
cls="flex items-center gap-2"
|
||||
),
|
||||
role="alert",
|
||||
cls="alert alert-success mb-4"
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Welcome page component for FastHTML application.
|
||||
|
||||
Provides a protected welcome page that displays user information.
|
||||
This page is only accessible to authenticated users.
|
||||
"""
|
||||
|
||||
from fasthtml.common import Div, H1, H2, P, Button, A
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class WelcomePage:
|
||||
"""
|
||||
Welcome page component for authenticated users.
|
||||
|
||||
Attributes:
|
||||
user_info: Dictionary containing user information (email, id, etc.)
|
||||
redirect_url: URL for logout redirect
|
||||
"""
|
||||
|
||||
def __init__(self, user_info: Optional[Dict[str, Any]] = None, redirect_url: str = "/login"):
|
||||
"""
|
||||
Initialize welcome page.
|
||||
|
||||
Args:
|
||||
user_info: Dictionary containing user information
|
||||
redirect_url: URL to redirect after logout (default: "/login")
|
||||
"""
|
||||
self.user_info = user_info or {}
|
||||
self.redirect_url = redirect_url
|
||||
|
||||
def _render(self):
|
||||
"""
|
||||
Render the welcome page HTML structure.
|
||||
|
||||
Returns:
|
||||
FastHTML component tree for the welcome page
|
||||
"""
|
||||
# Extract user info
|
||||
email = self.user_info.get('email', 'Guest')
|
||||
user_id = self.user_info.get('id', 'N/A')
|
||||
|
||||
# Hero section
|
||||
hero = Div(
|
||||
Div(
|
||||
# Welcome message
|
||||
H1(
|
||||
f"Welcome back, {email}!",
|
||||
cls="text-5xl font-bold"
|
||||
),
|
||||
|
||||
P(
|
||||
"You have successfully logged in to your account.",
|
||||
cls="py-6 text-lg"
|
||||
),
|
||||
|
||||
# Action buttons
|
||||
Div(
|
||||
Button(
|
||||
"Logout",
|
||||
cls="btn btn-primary",
|
||||
hx_post="/logout",
|
||||
hx_target="body",
|
||||
hx_swap="outerHTML"
|
||||
),
|
||||
cls="flex gap-2"
|
||||
),
|
||||
|
||||
cls="max-w-md"
|
||||
),
|
||||
cls="hero min-h-screen bg-base-200"
|
||||
)
|
||||
|
||||
# User info card
|
||||
info_card = Div(
|
||||
Div(
|
||||
H2("Your Information", cls="card-title mb-4"),
|
||||
|
||||
# User details
|
||||
Div(
|
||||
self._info_row("Email", email),
|
||||
self._info_row("User ID", str(user_id)),
|
||||
cls="space-y-2"
|
||||
),
|
||||
|
||||
cls="card-body"
|
||||
),
|
||||
cls="card bg-base-100 shadow-xl w-full max-w-md"
|
||||
)
|
||||
|
||||
# Main container
|
||||
return Div(
|
||||
# Hero section
|
||||
hero,
|
||||
|
||||
# Info card (positioned absolutely over hero)
|
||||
Div(
|
||||
info_card,
|
||||
cls="absolute top-20 right-20 hidden lg:block"
|
||||
),
|
||||
|
||||
cls="relative"
|
||||
)
|
||||
|
||||
def _info_row(self, label: str, value: str):
|
||||
"""
|
||||
Create an information row for displaying user details.
|
||||
|
||||
Args:
|
||||
label: Label for the information
|
||||
value: Value to display
|
||||
|
||||
Returns:
|
||||
Div containing label and value
|
||||
"""
|
||||
return Div(
|
||||
Div(
|
||||
P(label, cls="font-semibold text-sm text-base-content/70"),
|
||||
P(value, cls="text-base"),
|
||||
cls="flex flex-col"
|
||||
),
|
||||
cls="py-2 border-b border-base-300 last:border-b-0"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""
|
||||
FastHTML rendering protocol method.
|
||||
|
||||
Returns:
|
||||
Rendered welcome page component
|
||||
"""
|
||||
return self._render()
|
||||
|
||||
|
||||
class SimpleWelcomePage:
|
||||
"""
|
||||
Simpler version of welcome page without user info card.
|
||||
Useful for quick prototyping or minimal designs.
|
||||
"""
|
||||
|
||||
def __init__(self, user_email: str = "Guest"):
|
||||
"""
|
||||
Initialize simple welcome page.
|
||||
|
||||
Args:
|
||||
user_email: User's email address
|
||||
"""
|
||||
self.user_email = user_email
|
||||
|
||||
def _render(self):
|
||||
"""
|
||||
Render the simple welcome page.
|
||||
|
||||
Returns:
|
||||
FastHTML component tree
|
||||
"""
|
||||
return Div(
|
||||
Div(
|
||||
Div(
|
||||
H1(f"Welcome, {self.user_email}!", cls="text-4xl font-bold mb-4"),
|
||||
P("You are now logged in.", cls="mb-6 text-lg"),
|
||||
|
||||
Button(
|
||||
"Logout",
|
||||
cls="btn btn-primary",
|
||||
hx_post="/logout",
|
||||
hx_target="body",
|
||||
hx_swap="outerHTML"
|
||||
),
|
||||
|
||||
cls="text-center"
|
||||
),
|
||||
cls="hero-content"
|
||||
),
|
||||
cls="hero min-h-screen bg-base-200"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""
|
||||
FastHTML rendering protocol method.
|
||||
|
||||
Returns:
|
||||
Rendered simple welcome page
|
||||
"""
|
||||
return self._render()
|
||||
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Authentication routes for FastHTML application.
|
||||
|
||||
Provides routes for login, register, logout, and protected pages.
|
||||
"""
|
||||
|
||||
from fasthtml.common import RedirectResponse
|
||||
from myauth import create_auth_app_for_sqlite
|
||||
|
||||
from ..auth.pages.LoginPage import LoginPage
|
||||
from ..auth.pages.RegisterPage import RegisterPage
|
||||
from ..auth.pages.WelcomePage import WelcomePage
|
||||
from ..auth.utils import (
|
||||
login_user,
|
||||
register_user,
|
||||
logout_user,
|
||||
get_user_info
|
||||
)
|
||||
|
||||
|
||||
def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"):
|
||||
"""
|
||||
Setup all authentication and protected routes.
|
||||
|
||||
Args:
|
||||
app: FastHTML application instance
|
||||
rt: Route decorator from FastHTML
|
||||
mount_auth_app: Whether to mount the auth FastApi API routes
|
||||
sqlite_db_path: by default, create a new SQLite database at this path
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
# PUBLIC ROUTES (Login & Register)
|
||||
# ============================================================================
|
||||
|
||||
@rt("/login")
|
||||
def get(error: str = None):
|
||||
"""
|
||||
Display login page.
|
||||
|
||||
Args:
|
||||
error: Optional error message from query params
|
||||
|
||||
Returns:
|
||||
LoginPage component
|
||||
"""
|
||||
return LoginPage(error_message=error)
|
||||
|
||||
@rt("/login-p")
|
||||
def post(email: str, password: str, session, redirect_url: str = "/"):
|
||||
"""
|
||||
Handle login form submission.
|
||||
|
||||
Args:
|
||||
email: User email from form
|
||||
password: User password from form
|
||||
session: FastHTML session object
|
||||
redirect_url: URL to redirect after successful login
|
||||
|
||||
Returns:
|
||||
RedirectResponse on success, or LoginPage with error on failure
|
||||
"""
|
||||
# Attempt login
|
||||
auth_data = login_user(email, password)
|
||||
|
||||
if auth_data:
|
||||
# Login successful - store tokens in session
|
||||
session['access_token'] = auth_data['access_token']
|
||||
session['refresh_token'] = auth_data['refresh_token']
|
||||
|
||||
# Get user info and store in session
|
||||
user_info = get_user_info(auth_data['access_token'])
|
||||
if user_info:
|
||||
session['user_info'] = user_info
|
||||
|
||||
# Redirect to protected page
|
||||
return RedirectResponse(redirect_url, status_code=303)
|
||||
else:
|
||||
# Login failed - return error message via HTMX
|
||||
return LoginPage(error_message="Invalid email or password. Please try again.")
|
||||
|
||||
@rt("/register")
|
||||
def get(error: str = None):
|
||||
"""
|
||||
Display registration page.
|
||||
|
||||
Args:
|
||||
error: Optional error message from query params
|
||||
|
||||
Returns:
|
||||
RegisterPage component
|
||||
"""
|
||||
return RegisterPage(error_message=error)
|
||||
|
||||
@rt("/register-p")
|
||||
def post(email: str, username: str, password: str, confirm_password: str, session):
|
||||
"""
|
||||
Handle registration form submission.
|
||||
|
||||
Args:
|
||||
email: User email from form
|
||||
username: User name of the
|
||||
password: User password from form
|
||||
confirm_password: Password confirmation from form
|
||||
session: FastHTML session object
|
||||
|
||||
Returns:
|
||||
RegisterPage with success/error message via HTMX
|
||||
"""
|
||||
# Validate password confirmation
|
||||
if password != confirm_password:
|
||||
return RegisterPage(error_message="Passwords do not match. Please try again.")
|
||||
|
||||
# Validate password length
|
||||
if len(password) < 8:
|
||||
return RegisterPage(error_message="Password must be at least 8 characters long.")
|
||||
|
||||
# Attempt registration
|
||||
result = register_user(email, username, password)
|
||||
|
||||
if result:
|
||||
# Registration successful - show success message and auto-login
|
||||
auth_data = login_user(email, password)
|
||||
|
||||
if auth_data:
|
||||
# Store tokens in session
|
||||
session['access_token'] = auth_data['access_token']
|
||||
session['refresh_token'] = auth_data['refresh_token']
|
||||
|
||||
# Get user info and store in session
|
||||
user_info = get_user_info(auth_data['access_token'])
|
||||
if user_info:
|
||||
session['user_info'] = user_info
|
||||
|
||||
# Redirect to welcome page
|
||||
return RedirectResponse("/", status_code=303)
|
||||
else:
|
||||
# Auto-login failed, redirect to login page
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
else:
|
||||
# Registration failed
|
||||
return RegisterPage(error_message="Registration failed. Email may already be in use.")
|
||||
|
||||
# ============================================================================
|
||||
# PROTECTED ROUTES (Require authentication)
|
||||
# ============================================================================
|
||||
|
||||
@rt("/welcome")
|
||||
def get(session, auth):
|
||||
"""
|
||||
Alternative welcome page route (protected).
|
||||
|
||||
Args:
|
||||
session: FastHTML session object
|
||||
auth: User auth info from request scope
|
||||
|
||||
Returns:
|
||||
WelcomePage component
|
||||
"""
|
||||
user_info = session.get('user_info', {})
|
||||
return WelcomePage(user_info=user_info)
|
||||
|
||||
@rt("/logout")
|
||||
def post(session):
|
||||
"""
|
||||
Handle logout request.
|
||||
|
||||
Revokes refresh token and clears session.
|
||||
|
||||
Args:
|
||||
session: FastHTML session object
|
||||
|
||||
Returns:
|
||||
RedirectResponse to login page
|
||||
"""
|
||||
# Get refresh token from session
|
||||
refresh_token = session.get('refresh_token')
|
||||
|
||||
# Revoke refresh token on backend
|
||||
if refresh_token:
|
||||
logout_user(refresh_token)
|
||||
|
||||
# Clear session
|
||||
session.clear()
|
||||
|
||||
# Redirect to login page
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
def mount_auth_fastapi_api():
|
||||
# Mount FastAPI auth backend
|
||||
auth_api = create_auth_app_for_sqlite(sqlite_db_path, "jwt-secret-to-change")
|
||||
app.mount("/auth", auth_api)
|
||||
|
||||
if mount_auth_app:
|
||||
mount_auth_fastapi_api()
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Authentication utilities for FastHTML application.
|
||||
|
||||
This module provides:
|
||||
- JWT token validation and refresh logic
|
||||
- Beforeware for protecting routes
|
||||
- Helper functions for API communication
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import httpx
|
||||
from fasthtml.common import RedirectResponse, Beforeware
|
||||
from jose import jwt, JWTError
|
||||
|
||||
# Configuration
|
||||
API_BASE_URL = "http://localhost:5001" # Base URL for FastAPI backend
|
||||
JWT_SECRET = "jwt-secret-to-change" # Must match FastAPI secret
|
||||
JWT_ALGORITHM = "HS256"
|
||||
TOKEN_REFRESH_THRESHOLD_MINUTES = 5 # Refresh token if expires in less than 5 minutes
|
||||
|
||||
# Default patterns to skip authentication
|
||||
DEFAULT_SKIP_PATTERNS = [
|
||||
r'/favicon\.ico',
|
||||
r'/static/.*',
|
||||
r'.*\.css',
|
||||
r'.*\.js',
|
||||
'/login',
|
||||
'/login-p',
|
||||
'/register',
|
||||
'/register-p',
|
||||
'/logout',
|
||||
]
|
||||
|
||||
http_client = httpx
|
||||
|
||||
|
||||
def create_auth_beforeware(additional_patterns: Optional[List[str]] = None) -> Beforeware:
|
||||
"""
|
||||
Create a Beforeware instance for route protection.
|
||||
|
||||
Args:
|
||||
additional_patterns: Optional list of additional URL patterns to skip authentication
|
||||
|
||||
Returns:
|
||||
Beforeware instance configured for authentication
|
||||
"""
|
||||
patterns = DEFAULT_SKIP_PATTERNS.copy()
|
||||
if additional_patterns:
|
||||
patterns.extend(additional_patterns)
|
||||
|
||||
return Beforeware(auth_before, skip=patterns)
|
||||
|
||||
|
||||
def auth_before(request, session):
|
||||
"""
|
||||
Beforeware function that runs before each protected route.
|
||||
|
||||
Checks authentication status and automatically refreshes tokens if needed.
|
||||
Redirects to login if authentication fails.
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
session: FastHTML session object
|
||||
|
||||
Returns:
|
||||
RedirectResponse to login page if authentication fails, None otherwise
|
||||
"""
|
||||
# Get tokens from session
|
||||
access_token = session.get('access_token')
|
||||
refresh_token = session.get('refresh_token')
|
||||
print(f"path={request.scope['path']}, {session=}, {access_token=}, {refresh_token=}")
|
||||
# If no access token, redirect to login
|
||||
if not access_token:
|
||||
return RedirectResponse('/login', status_code=303)
|
||||
|
||||
# Validate access token and check expiration
|
||||
try:
|
||||
payload = decode_jwt(access_token)
|
||||
exp_timestamp = payload.get('exp')
|
||||
|
||||
if exp_timestamp:
|
||||
exp_datetime = datetime.fromtimestamp(exp_timestamp, tz=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
time_until_expiry = (exp_datetime - now).total_seconds() / 60
|
||||
|
||||
# If token expires in less than 5 minutes, try to refresh
|
||||
if time_until_expiry < TOKEN_REFRESH_THRESHOLD_MINUTES:
|
||||
if refresh_token:
|
||||
new_tokens = refresh_access_token(refresh_token)
|
||||
if new_tokens:
|
||||
# Update session with new tokens
|
||||
session['access_token'] = new_tokens['access_token']
|
||||
session['refresh_token'] = new_tokens['refresh_token']
|
||||
# Store auth info in request scope
|
||||
request.scope['auth'] = session.get('user_info')
|
||||
return None
|
||||
else:
|
||||
# Refresh failed, redirect to login
|
||||
return RedirectResponse('/login', status_code=303)
|
||||
else:
|
||||
# No refresh token available, redirect to login
|
||||
return RedirectResponse('/login', status_code=303)
|
||||
|
||||
# Token is valid, store auth info in request scope
|
||||
request.scope['auth'] = session.get('user_info')
|
||||
return None
|
||||
|
||||
except JWTError:
|
||||
# Token is invalid or expired, try to refresh
|
||||
if refresh_token:
|
||||
new_tokens = refresh_access_token(refresh_token)
|
||||
if new_tokens:
|
||||
# Update session with new tokens
|
||||
session['access_token'] = new_tokens['access_token']
|
||||
session['refresh_token'] = new_tokens['refresh_token']
|
||||
request.scope['auth'] = session.get('user_info')
|
||||
return None
|
||||
|
||||
# Could not refresh, redirect to login
|
||||
return RedirectResponse('/login', status_code=303)
|
||||
|
||||
|
||||
def decode_jwt(token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Decode and validate a JWT token.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Dictionary containing the token payload
|
||||
|
||||
Raises:
|
||||
JWTError: If token is invalid or expired
|
||||
"""
|
||||
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
|
||||
|
||||
def check_token_expiry(token: str) -> Optional[float]:
|
||||
"""
|
||||
Check how many minutes until token expires.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Minutes until expiration, or None if token is invalid
|
||||
"""
|
||||
try:
|
||||
payload = decode_jwt(token)
|
||||
exp_timestamp = payload.get('exp')
|
||||
|
||||
if exp_timestamp:
|
||||
exp_datetime = datetime.fromtimestamp(exp_timestamp, tz=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
time_until_expiry = (exp_datetime - now).total_seconds() / 60
|
||||
return time_until_expiry
|
||||
|
||||
return None
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def login_user(email: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Authenticate user with email and password.
|
||||
|
||||
Args:
|
||||
email: User email address
|
||||
password: User password
|
||||
|
||||
Returns:
|
||||
Dictionary containing access_token, refresh_token, and user_info if successful,
|
||||
None if authentication fails
|
||||
"""
|
||||
try:
|
||||
response = http_client.post(
|
||||
f"{API_BASE_URL}/auth/login",
|
||||
data={"username": email, "password": password},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
'access_token': data.get('access_token'),
|
||||
'refresh_token': data.get('refresh_token'),
|
||||
'token_type': data.get('token_type'),
|
||||
}
|
||||
|
||||
return None
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def register_user(email: str, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
Args:
|
||||
email: User email address
|
||||
username: User name
|
||||
password: User password
|
||||
|
||||
Returns:
|
||||
Dictionary containing success message if registration succeeds,
|
||||
None if registration fails
|
||||
"""
|
||||
try:
|
||||
response = http_client.post(
|
||||
f"{API_BASE_URL}/auth/register",
|
||||
json={"email": email, "username": username, "password": password},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
return response.json()
|
||||
|
||||
return None
|
||||
except httpx.HTTPError as ex:
|
||||
return None
|
||||
|
||||
|
||||
def refresh_access_token(refresh_token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Refresh the access token using a refresh token.
|
||||
|
||||
Args:
|
||||
refresh_token: Valid refresh token
|
||||
|
||||
Returns:
|
||||
Dictionary containing new access_token and refresh_token if successful,
|
||||
None if refresh fails
|
||||
"""
|
||||
try:
|
||||
response = http_client.post(
|
||||
f"{API_BASE_URL}/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
'access_token': data.get('access_token'),
|
||||
'refresh_token': data.get('refresh_token'),
|
||||
}
|
||||
|
||||
return None
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def get_user_info(access_token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get current user information using access token.
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
|
||||
Returns:
|
||||
Dictionary containing user information if successful,
|
||||
None if request fails
|
||||
"""
|
||||
try:
|
||||
response = http_client.get(
|
||||
f"{API_BASE_URL}/auth/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
return None
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def logout_user(refresh_token: str) -> bool:
|
||||
"""
|
||||
Logout user by revoking the refresh token.
|
||||
|
||||
Args:
|
||||
refresh_token: Refresh token to revoke
|
||||
|
||||
Returns:
|
||||
True if logout successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
response = http_client.post(
|
||||
f"{API_BASE_URL}/auth/logout",
|
||||
json={"refresh_token": refresh_token},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
@@ -2,6 +2,7 @@ import dataclasses
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Self
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from fastcore.xml import FT, to_xml
|
||||
@@ -130,6 +131,349 @@ class TestableElement:
|
||||
# Send the request
|
||||
return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data)
|
||||
|
||||
def _support_htmx(self):
|
||||
"""Check if the element supports HTMX."""
|
||||
return ('hx_get' in self.ft.attrs or
|
||||
'hx-get' in self.ft.attrs or
|
||||
'hx_post' in self.ft.attrs or
|
||||
'hx-post' in self.ft.attrs)
|
||||
|
||||
|
||||
class TestableForm(TestableElement):
|
||||
"""
|
||||
Represents an HTML form that can be filled and submitted in tests.
|
||||
"""
|
||||
|
||||
def __init__(self, client, source):
|
||||
"""
|
||||
Initialize a testable form.
|
||||
|
||||
Args:
|
||||
client: The MyTestClient instance.
|
||||
source: The source HTML string containing a form.
|
||||
"""
|
||||
super().__init__(client, source)
|
||||
self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form')
|
||||
self.fields_mapping = {} # link between the input label and the input name
|
||||
self.fields = {} # field name; field value
|
||||
self.select_fields = {} # list of possible options for 'select' input fields
|
||||
|
||||
self._update_fields_mapping()
|
||||
self.update_fields()
|
||||
|
||||
def update_fields(self):
|
||||
"""
|
||||
Update the fields dictionary with current form values and their proper types.
|
||||
|
||||
This method processes all input and select elements in the form:
|
||||
- Determines the appropriate Python type (str, int, float, bool) based on
|
||||
the HTML input type attribute and/or the value itself
|
||||
- For select elements, populates self.select_fields with available options
|
||||
- Stores the final typed values in self.fields
|
||||
|
||||
Type conversion priority:
|
||||
1. HTML type attribute (checkbox → bool, number → int/float, etc.)
|
||||
2. Value analysis fallback for ambiguous types (text/hidden/absent type)
|
||||
"""
|
||||
self.fields = {}
|
||||
self.select_fields = {}
|
||||
|
||||
# Process input fields
|
||||
for input_field in self.form.find_all('input'):
|
||||
name = input_field.get('name')
|
||||
if not name:
|
||||
continue
|
||||
|
||||
input_type = input_field.get('type', 'text').lower()
|
||||
raw_value = input_field.get('value', '')
|
||||
|
||||
# Type conversion based on input type
|
||||
if input_type == 'checkbox':
|
||||
# Checkbox: bool based on 'checked' attribute
|
||||
self.fields[name] = input_field.has_attr('checked')
|
||||
|
||||
elif input_type == 'radio':
|
||||
# Radio: str value (only if checked)
|
||||
if input_field.has_attr('checked'):
|
||||
self.fields[name] = raw_value
|
||||
elif name not in self.fields:
|
||||
# If no radio is checked yet, don't set a default
|
||||
pass
|
||||
|
||||
elif input_type == 'number':
|
||||
# Number: int or float based on value
|
||||
self.fields[name] = self._convert_number(raw_value)
|
||||
|
||||
else:
|
||||
# Other types (text, hidden, email, password, etc.): analyze value
|
||||
self.fields[name] = self._convert_value(raw_value)
|
||||
|
||||
# Process select fields
|
||||
for select_field in self.form.find_all('select'):
|
||||
name = select_field.get('name')
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Extract all options
|
||||
options = []
|
||||
selected_value = None
|
||||
|
||||
for option in select_field.find_all('option'):
|
||||
option_value = option.get('value', option.get_text(strip=True))
|
||||
option_text = option.get_text(strip=True)
|
||||
|
||||
options.append({
|
||||
'value': option_value,
|
||||
'text': option_text
|
||||
})
|
||||
|
||||
# Track selected option
|
||||
if option.has_attr('selected'):
|
||||
selected_value = option_value
|
||||
|
||||
# Store options list
|
||||
self.select_fields[name] = options
|
||||
|
||||
# Store selected value (or first option if none selected)
|
||||
if selected_value is not None:
|
||||
self.fields[name] = selected_value
|
||||
elif options:
|
||||
self.fields[name] = options[0]['value']
|
||||
|
||||
def fill(self, **kwargs):
|
||||
"""
|
||||
Fill the form with the given data.
|
||||
|
||||
Args:
|
||||
**kwargs: Field names and their values to fill in the form.
|
||||
"""
|
||||
for name, value in kwargs.items():
|
||||
field_name = self.translate(name)
|
||||
if field_name not in self.fields:
|
||||
raise ValueError(f"Invalid field name '{name}'.")
|
||||
self.fields[self.translate(name)] = value
|
||||
|
||||
def submit(self):
|
||||
"""
|
||||
Submit the form.
|
||||
|
||||
This method handles both HTMX-enabled forms and classic HTML form submissions:
|
||||
- If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request
|
||||
- Otherwise, simulates a classic browser form submission using the form's
|
||||
action and method attributes
|
||||
|
||||
Returns:
|
||||
The response from the form submission.
|
||||
|
||||
Raises:
|
||||
ValueError: If the form has no action attribute for classic submission.
|
||||
"""
|
||||
# Check if the form supports HTMX
|
||||
if self._support_htmx():
|
||||
return self._send_htmx_request(data=self.fields)
|
||||
|
||||
# Classic form submission
|
||||
action = self.form.get('action')
|
||||
if not action or action.strip() == '':
|
||||
raise ValueError(
|
||||
"The form has no 'action' attribute. "
|
||||
"Cannot submit a classic form without a target URL."
|
||||
)
|
||||
|
||||
method = self.form.get('method', 'post').upper()
|
||||
|
||||
# Prepare headers for classic form submission
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
# Send the request via the client
|
||||
return self.client.send_request(
|
||||
method=method,
|
||||
url=action,
|
||||
headers=headers,
|
||||
data=self.fields
|
||||
)
|
||||
|
||||
def translate(self, field):
|
||||
return self.fields_mapping.get(field, field)
|
||||
|
||||
def _update_fields_mapping(self):
|
||||
"""
|
||||
Build a mapping between label text and input field names.
|
||||
|
||||
This method finds all labels in the form and associates them with their
|
||||
corresponding input fields using the following priority order:
|
||||
1. Explicit association via 'for' attribute matching input 'id'
|
||||
2. Implicit association (label contains the input)
|
||||
3. Parent-level association with 'for'/'id'
|
||||
4. Proximity association (siblings in same parent)
|
||||
5. No label (use input name as key)
|
||||
|
||||
The mapping is stored in self.fields_mapping as {label_text: input_name}.
|
||||
For inputs without a name, the id is used. If neither exists, a generic
|
||||
key like "unnamed_0" is generated.
|
||||
"""
|
||||
self.fields_mapping = {}
|
||||
processed_inputs = set()
|
||||
unnamed_counter = 0
|
||||
|
||||
# Get all inputs in the form
|
||||
all_inputs = self.form.find_all('input')
|
||||
|
||||
# Priority 1 & 2: Explicit association (for/id) and implicit (nested)
|
||||
for label in self.form.find_all('label'):
|
||||
label_text = label.get_text(strip=True)
|
||||
|
||||
# Check for explicit association via 'for' attribute
|
||||
label_for = label.get('for')
|
||||
if label_for:
|
||||
input_field = self.form.find('input', id=label_for)
|
||||
if input_field:
|
||||
input_name = self._get_input_identifier(input_field, unnamed_counter)
|
||||
if input_name.startswith('unnamed_'):
|
||||
unnamed_counter += 1
|
||||
self.fields_mapping[label_text] = input_name
|
||||
processed_inputs.add(id(input_field))
|
||||
continue
|
||||
|
||||
# Check for implicit association (label contains input)
|
||||
input_field = label.find('input')
|
||||
if input_field:
|
||||
input_name = self._get_input_identifier(input_field, unnamed_counter)
|
||||
if input_name.startswith('unnamed_'):
|
||||
unnamed_counter += 1
|
||||
self.fields_mapping[label_text] = input_name
|
||||
processed_inputs.add(id(input_field))
|
||||
continue
|
||||
|
||||
# Priority 3 & 4: Parent-level associations
|
||||
for label in self.form.find_all('label'):
|
||||
label_text = label.get_text(strip=True)
|
||||
|
||||
# Skip if this label was already processed
|
||||
if label_text in self.fields_mapping:
|
||||
continue
|
||||
|
||||
parent = label.parent
|
||||
if parent:
|
||||
input_found = False
|
||||
|
||||
# Priority 3: Look for sibling input with matching for/id
|
||||
label_for = label.get('for')
|
||||
if label_for:
|
||||
for sibling in parent.find_all('input'):
|
||||
if sibling.get('id') == label_for and id(sibling) not in processed_inputs:
|
||||
input_name = self._get_input_identifier(sibling, unnamed_counter)
|
||||
if input_name.startswith('unnamed_'):
|
||||
unnamed_counter += 1
|
||||
self.fields_mapping[label_text] = input_name
|
||||
processed_inputs.add(id(sibling))
|
||||
input_found = True
|
||||
break
|
||||
|
||||
# Priority 4: Fallback to proximity if no input found yet
|
||||
if not input_found:
|
||||
for sibling in parent.find_all('input'):
|
||||
if id(sibling) not in processed_inputs:
|
||||
input_name = self._get_input_identifier(sibling, unnamed_counter)
|
||||
if input_name.startswith('unnamed_'):
|
||||
unnamed_counter += 1
|
||||
self.fields_mapping[label_text] = input_name
|
||||
processed_inputs.add(id(sibling))
|
||||
break
|
||||
|
||||
# Priority 5: Inputs without labels
|
||||
for input_field in all_inputs:
|
||||
if id(input_field) not in processed_inputs:
|
||||
input_name = self._get_input_identifier(input_field, unnamed_counter)
|
||||
if input_name.startswith('unnamed_'):
|
||||
unnamed_counter += 1
|
||||
self.fields_mapping[input_name] = input_name
|
||||
|
||||
@staticmethod
|
||||
def _get_input_identifier(input_field, counter):
|
||||
"""
|
||||
Get the identifier for an input field.
|
||||
|
||||
Args:
|
||||
input_field: The BeautifulSoup Tag object representing the input.
|
||||
counter: Current counter for unnamed inputs.
|
||||
|
||||
Returns:
|
||||
The input name, id, or a generated "unnamed_X" identifier.
|
||||
"""
|
||||
if input_field.get('name'):
|
||||
return input_field['name']
|
||||
elif input_field.get('id'):
|
||||
return input_field['id']
|
||||
else:
|
||||
return f"unnamed_{counter}"
|
||||
|
||||
@staticmethod
|
||||
def _convert_number(value):
|
||||
"""
|
||||
Convert a string value to int or float.
|
||||
|
||||
Args:
|
||||
value: String value to convert.
|
||||
|
||||
Returns:
|
||||
int, float, or empty string if conversion fails.
|
||||
"""
|
||||
if not value or value.strip() == '':
|
||||
return ''
|
||||
|
||||
try:
|
||||
# Try float first to detect decimal numbers
|
||||
if '.' in value or 'e' in value.lower():
|
||||
return float(value)
|
||||
else:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _convert_value(value):
|
||||
"""
|
||||
Analyze and convert a value to its appropriate type.
|
||||
|
||||
Conversion priority:
|
||||
1. Boolean keywords (true/false)
|
||||
2. Float (contains decimal point)
|
||||
3. Int (numeric)
|
||||
4. Empty string
|
||||
5. String (default)
|
||||
|
||||
Args:
|
||||
value: String value to convert.
|
||||
|
||||
Returns:
|
||||
Converted value with appropriate type (bool, float, int, or str).
|
||||
"""
|
||||
if not value or value.strip() == '':
|
||||
return ''
|
||||
|
||||
value_lower = value.lower().strip()
|
||||
|
||||
# Check for boolean
|
||||
if value_lower in ('true', 'false'):
|
||||
return value_lower == 'true'
|
||||
|
||||
# Check for numeric values
|
||||
try:
|
||||
# Check for float (has decimal point or scientific notation)
|
||||
if '.' in value or 'e' in value_lower:
|
||||
return float(value)
|
||||
# Try int
|
||||
else:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Default to string
|
||||
return value
|
||||
|
||||
|
||||
class MyTestClient:
|
||||
"""
|
||||
@@ -158,7 +502,7 @@ class MyTestClient:
|
||||
# make sure that the commands are mounted
|
||||
mount_commands(self.app)
|
||||
|
||||
def open(self, path: str):
|
||||
def open(self, path: str) -> Self:
|
||||
"""
|
||||
Open a page and store its content for subsequent assertions.
|
||||
|
||||
@@ -182,7 +526,9 @@ class MyTestClient:
|
||||
return self
|
||||
|
||||
def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None):
|
||||
if json_data is not None:
|
||||
json_data['session'] = self._session
|
||||
|
||||
res = self.client.request(
|
||||
method,
|
||||
url,
|
||||
@@ -199,7 +545,7 @@ class MyTestClient:
|
||||
self.set_content(res.text)
|
||||
return self
|
||||
|
||||
def should_see(self, text: str):
|
||||
def should_see(self, text: str) -> Self:
|
||||
"""
|
||||
Assert that the given text is present in the visible page content.
|
||||
|
||||
@@ -216,6 +562,11 @@ class MyTestClient:
|
||||
AssertionError: If the text is not found in the page content.
|
||||
ValueError: If no page has been opened yet.
|
||||
"""
|
||||
|
||||
def clean_text(txt):
|
||||
return "\n".join(line for line in txt.splitlines() if line.strip())
|
||||
|
||||
|
||||
if self._content is None:
|
||||
raise ValueError(
|
||||
"No page content available. Call open() before should_see()."
|
||||
@@ -226,7 +577,7 @@ class MyTestClient:
|
||||
if text not in visible_text:
|
||||
# Provide a snippet of the actual content for debugging
|
||||
snippet_length = 200
|
||||
content_snippet = (
|
||||
content_snippet = clean_text(
|
||||
visible_text[:snippet_length] + "..."
|
||||
if len(visible_text) > snippet_length
|
||||
else visible_text
|
||||
@@ -238,7 +589,7 @@ class MyTestClient:
|
||||
|
||||
return self
|
||||
|
||||
def should_not_see(self, text: str):
|
||||
def should_not_see(self, text: str) -> Self:
|
||||
"""
|
||||
Assert that the given text is NOT present in the visible page content.
|
||||
|
||||
@@ -281,7 +632,7 @@ class MyTestClient:
|
||||
|
||||
return self
|
||||
|
||||
def find_element(self, selector: str):
|
||||
def find_element(self, selector: str) -> TestableElement:
|
||||
"""
|
||||
Find a single HTML element using a CSS selector.
|
||||
|
||||
@@ -320,6 +671,40 @@ class MyTestClient:
|
||||
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
|
||||
)
|
||||
|
||||
def find_form(self, fields: list = None) -> TestableForm:
|
||||
"""
|
||||
Find a form element in the page content.
|
||||
Can provide title of the fields to ease the search
|
||||
:param fields:
|
||||
:return:
|
||||
"""
|
||||
if self._content is None:
|
||||
raise ValueError(
|
||||
"No page content available. Call open() before find_form()."
|
||||
)
|
||||
|
||||
results = self._soup.select("form")
|
||||
if len(results) == 0:
|
||||
raise AssertionError(
|
||||
f"No form found."
|
||||
)
|
||||
|
||||
if fields is None:
|
||||
remaining = [TestableForm(self, form) for form in results]
|
||||
else:
|
||||
remaining = []
|
||||
for form in results:
|
||||
testable_form = TestableForm(self, form)
|
||||
if all(testable_form.translate(field) in testable_form.fields for field in fields):
|
||||
remaining.append(testable_form)
|
||||
|
||||
if len(remaining) == 1:
|
||||
return remaining[0]
|
||||
else:
|
||||
raise AssertionError(
|
||||
f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1."
|
||||
)
|
||||
|
||||
def get_content(self) -> str:
|
||||
"""
|
||||
Get the raw HTML content of the last opened page.
|
||||
@@ -329,7 +714,7 @@ class MyTestClient:
|
||||
"""
|
||||
return self._content
|
||||
|
||||
def set_content(self, content: str):
|
||||
def set_content(self, content: str) -> Self:
|
||||
"""
|
||||
Set the HTML content and parse it with BeautifulSoup.
|
||||
|
||||
@@ -338,6 +723,7 @@ class MyTestClient:
|
||||
"""
|
||||
self._content = content
|
||||
self._soup = BeautifulSoup(content, 'html.parser')
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def _find_visible_text_element(soup, text: str):
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
import myfasthtml.auth.utils
|
||||
from myfasthtml.auth.routes import setup_auth_routes
|
||||
from myfasthtml.auth.utils import create_auth_beforeware, register_user
|
||||
from myfasthtml.core.testclient import MyTestClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class DummyUser:
|
||||
email: str
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def registered_user():
|
||||
user = DummyUser("user@email.com", "user", "#Passw0rd")
|
||||
register_user(user.email, user.username, user.password)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def app():
|
||||
beforeware = create_auth_beforeware()
|
||||
test_app, test_rt = fast_app(before=beforeware)
|
||||
setup_auth_routes(test_app, test_rt, mount_auth_app=True, sqlite_db_path="TestUsers.db")
|
||||
|
||||
@test_rt('/')
|
||||
def index(): return "You are now logged in !"
|
||||
|
||||
return test_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def rt(app):
|
||||
return app.route
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user(app):
|
||||
user = MyTestClient(app)
|
||||
previous = myfasthtml.auth.utils.http_client
|
||||
myfasthtml.auth.utils.http_client = user.client
|
||||
|
||||
yield user
|
||||
|
||||
myfasthtml.auth.utils.http_client = previous
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup():
|
||||
if os.path.exists("TestUsers.db"):
|
||||
os.remove("TestUsers.db")
|
||||
|
||||
|
||||
def test_i_can_see_login_page(user):
|
||||
user.open("/login")
|
||||
user.should_see("Sign In")
|
||||
user.should_see("Register here")
|
||||
user.find_form(fields=["Email", "Password"])
|
||||
|
||||
|
||||
def test_i_cannot_login_with_wrong_credentials(user):
|
||||
user.open("/login")
|
||||
form = user.find_form(fields=["Email", "Password"])
|
||||
form.fill(Email="user@email.com", Password="#Passw0rd")
|
||||
form.submit()
|
||||
user.should_see("Invalid email or password. Please try again.")
|
||||
|
||||
|
||||
def test_i_can_login_with_correct_credentials(user, registered_user):
|
||||
user.open("/login")
|
||||
form = user.find_form(fields=["Email", "Password"])
|
||||
form.fill(Email=registered_user.email, Password=registered_user.password)
|
||||
form.submit()
|
||||
user.should_see("You are now logged in !")
|
||||
|
||||
|
||||
def test_i_can_can_navigate_once_logged_in(user, registered_user):
|
||||
user.open("/welcome") # not logged in, redirects to login
|
||||
user.should_see("Sign In")
|
||||
|
||||
form = user.find_form(fields=["Email", "Password"])
|
||||
form.fill(Email=registered_user.email, Password=registered_user.password)
|
||||
form.submit()
|
||||
|
||||
user.open("/welcome") # once logged in, welcome page is accessible
|
||||
user.should_see("Welcome back, user@email.com!") # welcome page is predefined
|
||||
|
||||
|
||||
def test_i_can_register(user):
|
||||
user.open("/register")
|
||||
form = user.find_form(fields=["Email", "Username", "Password"])
|
||||
form.fill(Email="user@email.com", Username="username", Password="#Passw0rd", confirm_password="#Passw0rd")
|
||||
form.submit()
|
||||
|
||||
user.should_see("You are now logged in !")
|
||||
|
||||
|
||||
def test_i_can_logout(user, registered_user):
|
||||
user.open("/login")
|
||||
form = user.find_form(fields=["Email", "Password"])
|
||||
form.fill(Email=registered_user.email, Password=registered_user.password)
|
||||
form.submit()
|
||||
|
||||
user.open("/logout")
|
||||
user.should_see("Sign In")
|
||||
|
||||
user.open("/welcome")
|
||||
user.should_see("Sign In")
|
||||
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from myfasthtml.auth.utils import create_auth_beforeware
|
||||
from myfasthtml.core.testclient import MyTestClient
|
||||
|
||||
def test_non_protected_route():
|
||||
app, rt = fast_app()
|
||||
user = MyTestClient(app)
|
||||
|
||||
@rt('/')
|
||||
def index(): return "Welcome"
|
||||
|
||||
@rt('/login')
|
||||
def index(): return "Sign In"
|
||||
|
||||
user.open("/")
|
||||
user.should_see("Welcome")
|
||||
|
||||
|
||||
def test_all_routes_are_protected():
|
||||
beforeware = create_auth_beforeware()
|
||||
app, rt = fast_app(before=beforeware)
|
||||
user = MyTestClient(app)
|
||||
|
||||
@rt('/')
|
||||
def index(): return "Welcome"
|
||||
|
||||
@rt('/login')
|
||||
def index(): return "Sign In"
|
||||
|
||||
user.open("/")
|
||||
user.should_see("Sign In")
|
||||
@@ -1,326 +0,0 @@
|
||||
import pytest
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from myfasthtml.core.testclient import MyTestClient, TestableElement
|
||||
|
||||
|
||||
def test_i_can_open_a_page():
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get(): return "hello world"
|
||||
|
||||
client.open("/")
|
||||
|
||||
assert client.get_content() == "hello world"
|
||||
|
||||
|
||||
def test_i_can_open_a_page_when_html():
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get(): return Div("hello world")
|
||||
|
||||
client.open("/")
|
||||
|
||||
assert client.get_content() == ' <!doctype html>\n <html>\n <head>\n <title>FastHTML page</title>\n <link rel="canonical" href="http://testserver/">\n </head>\n <body>\n <div>hello world</div>\n </body>\n </html>\n'
|
||||
|
||||
|
||||
def test_i_cannot_open_a_page_not_defined():
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/not_found")
|
||||
|
||||
assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'"
|
||||
|
||||
def test_i_can_see_text_in_plain_response():
|
||||
"""Test that should_see() works with plain text responses."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "hello world"
|
||||
|
||||
client.open("/").should_see("hello world")
|
||||
|
||||
|
||||
def test_i_can_see_text_in_html_response():
|
||||
"""Test that should_see() extracts visible text from HTML responses."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "<html><body><h1>Welcome</h1><p>This is a test</p></body></html>"
|
||||
|
||||
client.open("/").should_see("Welcome").should_see("This is a test")
|
||||
|
||||
|
||||
def test_i_can_see_text_ignoring_html_tags():
|
||||
"""Test that should_see() searches in visible text only, not in HTML tags."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div class="container">Content</div>'
|
||||
|
||||
# Should find the visible text
|
||||
client.open("/").should_see("Content")
|
||||
|
||||
# Should NOT find text that's only in attributes/tags
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.should_see("container")
|
||||
|
||||
assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_i_cannot_see_text_that_is_not_present():
|
||||
"""Test that should_see() raises AssertionError when text is not found."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "hello world"
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_see("goodbye")
|
||||
|
||||
assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value)
|
||||
assert "hello world" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_i_cannot_call_should_see_without_opening_page():
|
||||
"""Test that should_see() raises ValueError if no page has been opened."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
client.should_see("anything")
|
||||
|
||||
assert str(exc_info.value) == "No page content available. Call open() before should_see()."
|
||||
|
||||
|
||||
def test_i_can_verify_text_is_not_present():
|
||||
"""Test that should_not_see() works when text is absent."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "hello world"
|
||||
|
||||
client.open("/").should_not_see("goodbye")
|
||||
|
||||
|
||||
def test_i_cannot_use_should_not_see_when_text_is_present():
|
||||
"""Test that should_not_see() raises AssertionError when text is found."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "hello world"
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("hello")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Expected NOT to see 'hello' in page content but it was found" in error_message
|
||||
|
||||
|
||||
def test_i_cannot_call_should_not_see_without_opening_page():
|
||||
"""Test that should_not_see() raises ValueError if no page has been opened."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
client.should_not_see("anything")
|
||||
|
||||
assert str(exc_info.value) == "No page content available. Call open() before should_not_see()."
|
||||
|
||||
|
||||
def test_i_can_chain_multiple_assertions():
|
||||
"""Test that assertions can be chained together."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "<html><body><h1>Welcome</h1><p>Content here</p></body></html>"
|
||||
|
||||
# Chain multiple assertions
|
||||
client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error")
|
||||
|
||||
|
||||
def test_i_can_see_element_context_when_text_should_not_be_seen():
|
||||
"""Test that the HTML element containing the text is displayed with parent context."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div class="container"><p class="content">forbidden text</p></div>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("forbidden text")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Found in:" in error_message
|
||||
assert '<p class="content">forbidden text</p>' in error_message
|
||||
assert '<div class="container">' in error_message
|
||||
|
||||
|
||||
def test_i_can_configure_parent_levels_in_constructor():
|
||||
"""Test that parent_levels parameter controls the number of parent levels shown."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app, parent_levels=2)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<body><div class="wrapper"><div class="container"><p>error</p></div></div></body>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("error")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert '<p>error</p>' in error_message
|
||||
assert '<div class="container">' in error_message
|
||||
assert '<div class="wrapper">' in error_message
|
||||
|
||||
|
||||
def test_i_can_find_text_in_nested_elements():
|
||||
"""Test that the smallest element containing the text is found in nested structures."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div><section><article><p class="target">nested text</p></article></section></div>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("nested text")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
# Should find the <p> element, not the outer <div>
|
||||
assert '<p class="target">nested text</p>' in error_message
|
||||
|
||||
|
||||
def test_i_can_find_fragmented_text_across_tags():
|
||||
"""Test that text fragmented across multiple tags is correctly found."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<p class="message">hel<span>lo</span> world</p>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("hello world")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
# Should find the parent <p> element that contains the full text
|
||||
assert '<p class="message">' in error_message
|
||||
|
||||
|
||||
def test_i_do_not_find_text_in_html_attributes():
|
||||
"""Test that text in HTML attributes is not considered as visible text."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div class="error message" title="error info">Success</div>'
|
||||
|
||||
# "error" is in attributes but not in visible text
|
||||
client.open("/").should_not_see("error")
|
||||
|
||||
# "Success" is in visible text
|
||||
with pytest.raises(AssertionError):
|
||||
client.should_not_see("Success")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("selector,expected_tag", [
|
||||
("#unique-id", '<div class="main wrapper"'),
|
||||
(".home-link", '<a class="link home-link"'),
|
||||
("div > div", '<div class="content"'),
|
||||
("div span", "<span"),
|
||||
("[data-type]", '<a class="link home-link"'),
|
||||
('[data-type="navigation"]', '<a class="link home-link"'),
|
||||
('[class~="link"]', '<a class="link home-link"'),
|
||||
('[href^="/home"]', '<a class="link home-link"'),
|
||||
('[href$="about"]', '<a href="/about">'),
|
||||
('[data-author*="john"]', '<a class="link home-link"'),
|
||||
])
|
||||
def test_i_can_find_element(selector, expected_tag):
|
||||
"""Test that find_element works with various CSS selectors."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '''
|
||||
<div id="unique-id" class="main wrapper">
|
||||
<a href="/home" class="link home-link" data-type="navigation" data-author="john-doe">Home</a>
|
||||
<a href="/about">About</a>
|
||||
<div class="content">
|
||||
<span class="text">Content</span>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
element = client.open("/").find_element(selector)
|
||||
|
||||
assert element is not None
|
||||
assert isinstance(element, TestableElement)
|
||||
assert expected_tag in element.html_fragment
|
||||
|
||||
|
||||
def test_i_cannot_find_element_when_none_exists():
|
||||
"""Test that find_element raises AssertionError when no element matches."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div class="container"><p>Content</p></div>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").find_element("#non-existent")
|
||||
|
||||
assert "No element found matching selector '#non-existent'" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_i_cannot_find_element_when_multiple_exist():
|
||||
"""Test that find_element raises AssertionError when multiple elements match."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div><p class="text">First</p><p class="text">Second</p><p class="text">Third</p></div>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").find_element(".text")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Found 3 elements matching selector '.text'" in error_message
|
||||
assert "Expected exactly 1" in error_message
|
||||
|
||||
|
||||
def test_i_cannot_call_find_element_without_opening_page():
|
||||
"""Test that find_element raises ValueError if no page has been opened."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
client.find_element("#any-selector")
|
||||
|
||||
assert str(exc_info.value) == "No page content available. Call open() before find_element()."
|
||||
@@ -0,0 +1,484 @@
|
||||
import pytest
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from myfasthtml.core.testclient import MyTestClient, TestableElement, TestableForm
|
||||
|
||||
|
||||
class TestMyTestClientOpen:
|
||||
|
||||
def test_i_can_open_a_page(self):
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get(): return "hello world"
|
||||
|
||||
client.open("/")
|
||||
|
||||
assert client.get_content() == "hello world"
|
||||
|
||||
def test_i_can_open_a_page_when_html(self):
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get(): return Div("hello world")
|
||||
|
||||
client.open("/")
|
||||
|
||||
assert client.get_content() == ' <!doctype html>\n <html>\n <head>\n <title>FastHTML page</title>\n <link rel="canonical" href="http://testserver/">\n </head>\n <body>\n <div>hello world</div>\n </body>\n </html>\n'
|
||||
|
||||
def test_i_cannot_open_a_page_not_defined(self):
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/not_found")
|
||||
|
||||
assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'"
|
||||
|
||||
|
||||
class TestMyTestClientShouldSee:
|
||||
def test_i_can_see_text_in_plain_response(self):
|
||||
"""Test that should_see() works with plain text responses."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "hello world"
|
||||
|
||||
client.open("/").should_see("hello world")
|
||||
|
||||
def test_i_can_see_text_in_html_response(self):
|
||||
"""Test that should_see() extracts visible text from HTML responses."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "<html><body><h1>Welcome</h1><p>This is a test</p></body></html>"
|
||||
|
||||
client.open("/").should_see("Welcome").should_see("This is a test")
|
||||
|
||||
def test_i_can_see_text_ignoring_html_tags(self):
|
||||
"""Test that should_see() searches in visible text only, not in HTML tags."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div class="container">Content</div>'
|
||||
|
||||
# Should find the visible text
|
||||
client.open("/").should_see("Content")
|
||||
|
||||
# Should NOT find text that's only in attributes/tags
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.should_see("container")
|
||||
|
||||
assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value)
|
||||
|
||||
def test_i_cannot_see_text_that_is_not_present(self):
|
||||
"""Test that should_see() raises AssertionError when text is not found."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "hello world"
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_see("goodbye")
|
||||
|
||||
assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value)
|
||||
assert "hello world" in str(exc_info.value)
|
||||
|
||||
def test_i_cannot_call_should_see_without_opening_page(self):
|
||||
"""Test that should_see() raises ValueError if no page has been opened."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
client.should_see("anything")
|
||||
|
||||
assert str(exc_info.value) == "No page content available. Call open() before should_see()."
|
||||
|
||||
def test_i_can_verify_text_is_not_present(self):
|
||||
"""Test that should_not_see() works when text is absent."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "hello world"
|
||||
|
||||
client.open("/").should_not_see("goodbye")
|
||||
|
||||
def test_i_cannot_use_should_not_see_when_text_is_present(self):
|
||||
"""Test that should_not_see() raises AssertionError when text is found."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "hello world"
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("hello")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Expected NOT to see 'hello' in page content but it was found" in error_message
|
||||
|
||||
def test_i_cannot_call_should_not_see_without_opening_page(self):
|
||||
"""Test that should_not_see() raises ValueError if no page has been opened."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
client.should_not_see("anything")
|
||||
|
||||
assert str(exc_info.value) == "No page content available. Call open() before should_not_see()."
|
||||
|
||||
def test_i_can_chain_multiple_assertions(self):
|
||||
"""Test that assertions can be chained together."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return "<html><body><h1>Welcome</h1><p>Content here</p></body></html>"
|
||||
|
||||
# Chain multiple assertions
|
||||
client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error")
|
||||
|
||||
def test_i_can_see_element_context_when_text_should_not_be_seen(self):
|
||||
"""Test that the HTML element containing the text is displayed with parent context."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div class="container"><p class="content">forbidden text</p></div>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("forbidden text")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Found in:" in error_message
|
||||
assert '<p class="content">forbidden text</p>' in error_message
|
||||
assert '<div class="container">' in error_message
|
||||
|
||||
def test_i_can_configure_parent_levels_in_constructor(self):
|
||||
"""Test that parent_levels parameter controls the number of parent levels shown."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app, parent_levels=2)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<body><div class="wrapper"><div class="container"><p>error</p></div></div></body>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("error")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert '<p>error</p>' in error_message
|
||||
assert '<div class="container">' in error_message
|
||||
assert '<div class="wrapper">' in error_message
|
||||
|
||||
def test_i_can_find_text_in_nested_elements(self):
|
||||
"""Test that the smallest element containing the text is found in nested structures."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div><section><article><p class="target">nested text</p></article></section></div>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("nested text")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
# Should find the <p> element, not the outer <div>
|
||||
assert '<p class="target">nested text</p>' in error_message
|
||||
|
||||
def test_i_can_find_fragmented_text_across_tags(self):
|
||||
"""Test that text fragmented across multiple tags is correctly found."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<p class="message">hel<span>lo</span> world</p>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").should_not_see("hello world")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
# Should find the parent <p> element that contains the full text
|
||||
assert '<p class="message">' in error_message
|
||||
|
||||
def test_i_do_not_find_text_in_html_attributes(self):
|
||||
"""Test that text in HTML attributes is not considered as visible text."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div class="error message" title="error info">Success</div>'
|
||||
|
||||
# "error" is in attributes but not in visible text
|
||||
client.open("/").should_not_see("error")
|
||||
|
||||
# "Success" is in visible text
|
||||
with pytest.raises(AssertionError):
|
||||
client.should_not_see("Success")
|
||||
|
||||
|
||||
class TestMyTestClientFindElement:
|
||||
|
||||
@pytest.mark.parametrize("selector,expected_tag", [
|
||||
("#unique-id", '<div class="main wrapper"'),
|
||||
(".home-link", '<a class="link home-link"'),
|
||||
("div > div", '<div class="content"'),
|
||||
("div span", "<span"),
|
||||
("[data-type]", '<a class="link home-link"'),
|
||||
('[data-type="navigation"]', '<a class="link home-link"'),
|
||||
('[class~="link"]', '<a class="link home-link"'),
|
||||
('[href^="/home"]', '<a class="link home-link"'),
|
||||
('[href$="about"]', '<a href="/about">'),
|
||||
('[data-author*="john"]', '<a class="link home-link"'),
|
||||
])
|
||||
def test_i_can_find_element(self, selector, expected_tag):
|
||||
"""Test that find_element works with various CSS selectors."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '''
|
||||
<div id="unique-id" class="main wrapper">
|
||||
<a href="/home" class="link home-link" data-type="navigation" data-author="john-doe">Home</a>
|
||||
<a href="/about">About</a>
|
||||
<div class="content">
|
||||
<span class="text">Content</span>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
element = client.open("/").find_element(selector)
|
||||
|
||||
assert element is not None
|
||||
assert isinstance(element, TestableElement)
|
||||
assert expected_tag in element.html_fragment
|
||||
|
||||
def test_i_cannot_find_element_when_none_exists(self):
|
||||
"""Test that find_element raises AssertionError when no element matches."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div class="container"><p>Content</p></div>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").find_element("#non-existent")
|
||||
|
||||
assert "No element found matching selector '#non-existent'" in str(exc_info.value)
|
||||
|
||||
def test_i_cannot_find_element_when_multiple_exist(self):
|
||||
"""Test that find_element raises AssertionError when multiple elements match."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div><p class="text">First</p><p class="text">Second</p><p class="text">Third</p></div>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").find_element(".text")
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Found 3 elements matching selector '.text'" in error_message
|
||||
assert "Expected exactly 1" in error_message
|
||||
|
||||
def test_i_cannot_call_find_element_without_opening_page(self):
|
||||
"""Test that find_element raises ValueError if no page has been opened."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
client.find_element("#any-selector")
|
||||
|
||||
assert str(exc_info.value) == "No page content available. Call open() before find_element()."
|
||||
|
||||
|
||||
class TestMyTestClientFindForm:
|
||||
def test_i_can_find_form(self):
|
||||
"""Test that find_form works fo simple form."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '''<form></form>'''
|
||||
|
||||
form = client.open("/").find_form()
|
||||
|
||||
assert form is not None
|
||||
assert isinstance(form, TestableForm)
|
||||
|
||||
def test_i_can_find_form_in_nested_elements(self):
|
||||
"""Test that find_form works when form is nested in other elements."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '''
|
||||
<div class="container">
|
||||
<div class="wrapper">
|
||||
<form></form>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
|
||||
form = client.open("/").find_form()
|
||||
|
||||
assert form is not None
|
||||
assert isinstance(form, TestableForm)
|
||||
|
||||
def test_i_can_find_form_with_all_fields(self):
|
||||
"""Test that find_form works when form has fields."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '''
|
||||
<form>
|
||||
<input type="text" name="username" placeholder="Username">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
'''
|
||||
|
||||
form = client.open("/").find_form(fields=["username", "password"])
|
||||
|
||||
assert form is not None
|
||||
assert isinstance(form, TestableForm)
|
||||
|
||||
def test_i_can_find_form_with_label(self):
|
||||
"""Test that find_form works when form has fields."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '''
|
||||
<form>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" placeholder="Username">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
'''
|
||||
|
||||
form = client.open("/").find_form(fields=["Username", "password"])
|
||||
|
||||
assert form is not None
|
||||
assert isinstance(form, TestableForm)
|
||||
|
||||
def test_i_can_find_form_with_one_field(self):
|
||||
"""Test that find_form works when form has at least one field."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '''
|
||||
<form>
|
||||
<input type="text" name="username" placeholder="Username">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
'''
|
||||
|
||||
form = client.open("/").find_form(fields=["username"])
|
||||
|
||||
assert form is not None
|
||||
assert isinstance(form, TestableForm)
|
||||
|
||||
def test_i_cannot_find_element_when_none_exists(self):
|
||||
"""Test that find_form raises AssertionError when no form found."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '<div class="container"><p>Content</p></div>'
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").find_form()
|
||||
|
||||
assert "No form found" in str(exc_info.value)
|
||||
|
||||
def test_i_cannot_call_find_form_without_opening_page(self):
|
||||
"""Test that find_form raises ValueError if no page has been opened."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
client.find_form()
|
||||
|
||||
assert str(exc_info.value) == "No page content available. Call open() before find_form()."
|
||||
|
||||
def test_cannot_find_form_when_multiple_exist(self):
|
||||
"""Test that find_form raises AssertionError when multiple forms match."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '''
|
||||
<div class="container">
|
||||
<form></form>
|
||||
<form></form>
|
||||
</div>
|
||||
'''
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").find_form()
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message
|
||||
|
||||
def test_cannot_find_form_with_fields_when_multiple_exist(self):
|
||||
"""Test that find_form raises AssertionError when multiple forms match."""
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
|
||||
@rt('/')
|
||||
def get():
|
||||
return '''
|
||||
<div class="container">
|
||||
<form>
|
||||
<input type="text" name="username" placeholder="Username">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
<form>
|
||||
<input type="text" name="username" placeholder="Username">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
</div>
|
||||
'''
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
client.open("/").find_form(fields=["username", "password"])
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message
|
||||
|
||||
@@ -0,0 +1,922 @@
|
||||
import pytest
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from myfasthtml.core.testclient import TestableForm, MyTestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Mock client for testing purposes."""
|
||||
return None
|
||||
|
||||
|
||||
class TestableFormUpdateFieldMapping:
|
||||
def test_i_can_map_label_with_explicit_for_attribute(self, mock_client):
|
||||
"""
|
||||
Test that labels with explicit 'for' attribute are correctly mapped.
|
||||
|
||||
This is the most reliable association method (priority 1).
|
||||
"""
|
||||
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"Username": "username"}
|
||||
|
||||
def test_i_can_map_label_containing_input(self, mock_client):
|
||||
"""
|
||||
Test that labels containing inputs are correctly mapped.
|
||||
|
||||
This tests implicit association by nesting (priority 2).
|
||||
"""
|
||||
html = '<form><label>Username<input name="username" /></label></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"Username": "username"}
|
||||
|
||||
def test_i_can_map_label_and_input_as_siblings_with_for_id(self, mock_client):
|
||||
"""
|
||||
Test that sibling labels and inputs with for/id are correctly mapped.
|
||||
|
||||
This tests parent-level association with explicit for/id (priority 3).
|
||||
"""
|
||||
html = '<form><div><label for="uid">Username</label><input id="uid" name="username" /></div></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"Username": "username"}
|
||||
|
||||
def test_i_can_map_label_and_input_as_siblings_by_proximity(self, mock_client):
|
||||
"""
|
||||
Test that sibling labels and inputs are mapped by proximity.
|
||||
|
||||
This tests association by proximity without for/id (priority 4).
|
||||
"""
|
||||
html = '<form><div><label>Username</label><input name="username" /></div></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"Username": "username"}
|
||||
|
||||
def test_i_can_map_input_without_label_using_name(self, mock_client):
|
||||
"""
|
||||
Test that inputs without labels use their name attribute as key.
|
||||
|
||||
This tests the fallback mechanism (priority 5).
|
||||
"""
|
||||
html = '<form><input name="csrf_token" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"csrf_token": "csrf_token"}
|
||||
|
||||
def test_i_can_map_input_without_name_using_id(self, mock_client):
|
||||
"""
|
||||
Test that inputs without name attribute fallback to id attribute.
|
||||
|
||||
This ensures inputs without name can still be identified.
|
||||
"""
|
||||
html = '<form><input id="submit_btn" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"submit_btn": "submit_btn"}
|
||||
|
||||
def test_i_can_map_input_without_name_and_id_using_unnamed(self, mock_client):
|
||||
"""
|
||||
Test that inputs without name or id get a generated unnamed key.
|
||||
|
||||
This ensures all inputs are tracked even without identifiers.
|
||||
"""
|
||||
html = '<form><input type="submit" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"unnamed_0": "unnamed_0"}
|
||||
|
||||
def test_i_can_handle_multiple_unnamed_inputs(self, mock_client):
|
||||
"""
|
||||
Test that multiple unnamed inputs get incrementing counters.
|
||||
|
||||
This ensures each unnamed input has a unique identifier.
|
||||
"""
|
||||
html = '<form><input type="submit" /><input type="button" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"unnamed_0": "unnamed_0", "unnamed_1": "unnamed_1"}
|
||||
|
||||
def test_i_can_strip_whitespace_from_label_text(self, mock_client):
|
||||
"""
|
||||
Test that whitespace and newlines are stripped from label text.
|
||||
|
||||
This ensures clean, consistent label keys in the mapping.
|
||||
"""
|
||||
html = '<form><label for="uid"> Username \n</label><input id="uid" name="username" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"Username": "username"}
|
||||
|
||||
def test_i_can_extract_text_from_complex_labels(self, mock_client):
|
||||
"""
|
||||
Test that text from nested elements in labels is extracted.
|
||||
|
||||
This ensures labels with spans, emphasis, etc. are handled correctly.
|
||||
"""
|
||||
html = '<form><label for="uid">Username <span class="required">*</span></label><input id="uid" name="username" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"Username*": "username"}
|
||||
|
||||
def test_i_can_handle_mixed_scenarios_in_same_form(self, mock_client):
|
||||
"""
|
||||
Test that all association priorities work together in one form.
|
||||
|
||||
This is a comprehensive test ensuring the priority system works correctly.
|
||||
"""
|
||||
html = '''
|
||||
<form>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" />
|
||||
|
||||
<label>Password<input name="password" /></label>
|
||||
|
||||
<div>
|
||||
<label for="phone">Phone</label>
|
||||
<input id="phone" name="phone" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Address</label>
|
||||
<input name="address" />
|
||||
</div>
|
||||
|
||||
<input name="csrf_token" />
|
||||
<input id="submit_btn" />
|
||||
<input type="hidden" />
|
||||
</form>
|
||||
'''
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
expected = {
|
||||
"Email": "email",
|
||||
"Password": "password",
|
||||
"Phone": "phone",
|
||||
"Address": "address",
|
||||
"csrf_token": "csrf_token",
|
||||
"submit_btn": "submit_btn",
|
||||
"unnamed_0": "unnamed_0"
|
||||
}
|
||||
|
||||
assert form.fields_mapping == expected
|
||||
|
||||
def test_i_can_handle_empty_form(self, mock_client):
|
||||
"""
|
||||
Test that an empty form doesn't cause errors.
|
||||
|
||||
This ensures robustness when dealing with minimal forms.
|
||||
"""
|
||||
html = '<form></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {}
|
||||
|
||||
def test_i_can_handle_form_with_only_labels(self, mock_client):
|
||||
"""
|
||||
Test that labels without associated inputs don't cause errors.
|
||||
|
||||
This ensures the code handles malformed or incomplete forms gracefully.
|
||||
"""
|
||||
html = '<form><label>Test</label></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {}
|
||||
|
||||
def test_i_can_handle_label_with_invalid_for_attribute(self, mock_client):
|
||||
"""
|
||||
Test that labels with invalid 'for' attributes fallback correctly.
|
||||
|
||||
This ensures the priority system cascades properly when higher
|
||||
priorities fail to find a match.
|
||||
"""
|
||||
html = '<form><div><label for="nonexistent">Test</label><input name="field" /></div></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields_mapping == {"Test": "field"}
|
||||
|
||||
|
||||
class TestableFormUpdateFieldValues:
|
||||
def test_i_can_handle_checkbox_checked(self, mock_client):
|
||||
"""
|
||||
Test that a checked checkbox is converted to True.
|
||||
|
||||
This ensures proper boolean handling for checked checkboxes.
|
||||
"""
|
||||
html = '<form><input type="checkbox" name="agree" checked /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"agree": True}, \
|
||||
f"Expected {{'agree': True}}, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_checkbox_unchecked(self, mock_client):
|
||||
"""
|
||||
Test that an unchecked checkbox is converted to False.
|
||||
|
||||
This ensures proper boolean handling for unchecked checkboxes.
|
||||
"""
|
||||
html = '<form><input type="checkbox" name="agree" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"agree": False}, \
|
||||
f"Expected {{'agree': False}}, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_radio_button_checked(self, mock_client):
|
||||
"""
|
||||
Test that a checked radio button returns its value as string.
|
||||
|
||||
This ensures radio buttons store their value attribute.
|
||||
"""
|
||||
html = '<form><input type="radio" name="size" value="large" checked /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"size": "large"}, \
|
||||
f"Expected {{'size': 'large'}}, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_multiple_radio_buttons_with_one_checked(self, mock_client):
|
||||
"""
|
||||
Test that only the checked radio button value is returned.
|
||||
|
||||
This ensures correct handling of radio button groups.
|
||||
"""
|
||||
html = '''
|
||||
<form>
|
||||
<input type="radio" name="size" value="small" />
|
||||
<input type="radio" name="size" value="medium" checked />
|
||||
<input type="radio" name="size" value="large" />
|
||||
</form>
|
||||
'''
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"size": "medium"}, \
|
||||
f"Expected {{'size': 'medium'}}, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_radio_buttons_with_none_checked(self, mock_client):
|
||||
"""
|
||||
Test that no value is set when no radio button is checked.
|
||||
|
||||
This ensures proper handling of unchecked radio button groups.
|
||||
"""
|
||||
html = '''
|
||||
<form>
|
||||
<input type="radio" name="size" value="small" />
|
||||
<input type="radio" name="size" value="medium" />
|
||||
<input type="radio" name="size" value="large" />
|
||||
</form>
|
||||
'''
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert "size" not in form.fields, \
|
||||
f"Expected 'size' not in fields, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_number_input_with_integer(self, mock_client):
|
||||
"""
|
||||
Test that a number input with integer value becomes int.
|
||||
|
||||
This ensures proper type conversion for integer numbers.
|
||||
"""
|
||||
html = '<form><input type="number" name="age" value="25" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"age": 25}, \
|
||||
f"Expected {{'age': 25}}, got {form.fields}"
|
||||
assert isinstance(form.fields["age"], int), \
|
||||
f"Expected int type, got {type(form.fields['age'])}"
|
||||
|
||||
def test_i_can_handle_number_input_with_float(self, mock_client):
|
||||
"""
|
||||
Test that a number input with decimal value becomes float.
|
||||
|
||||
This ensures proper type conversion for floating point numbers.
|
||||
"""
|
||||
html = '<form><input type="number" name="price" value="19.99" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"price": 19.99}, \
|
||||
f"Expected {{'price': 19.99}}, got {form.fields}"
|
||||
assert isinstance(form.fields["price"], float), \
|
||||
f"Expected float type, got {type(form.fields['price'])}"
|
||||
|
||||
def test_i_can_handle_text_input_with_string_value(self, mock_client):
|
||||
"""
|
||||
Test that a text input with string value remains str.
|
||||
|
||||
This ensures text values are not converted unnecessarily.
|
||||
"""
|
||||
html = '<form><input type="text" name="username" value="john_doe" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"username": "john_doe"}, \
|
||||
f"Expected {{'username': 'john_doe'}}, got {form.fields}"
|
||||
assert isinstance(form.fields["username"], str), \
|
||||
f"Expected str type, got {type(form.fields['username'])}"
|
||||
|
||||
def test_i_can_handle_text_input_with_integer_value(self, mock_client):
|
||||
"""
|
||||
Test that a text input with numeric value is converted to int.
|
||||
|
||||
This ensures automatic type detection for text inputs.
|
||||
"""
|
||||
html = '<form><input type="text" name="code" value="123" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"code": 123}, \
|
||||
f"Expected {{'code': 123}}, got {form.fields}"
|
||||
assert isinstance(form.fields["code"], int), \
|
||||
f"Expected int type, got {type(form.fields['code'])}"
|
||||
|
||||
def test_i_can_handle_text_input_with_float_value(self, mock_client):
|
||||
"""
|
||||
Test that a text input with decimal value is converted to float.
|
||||
|
||||
This ensures automatic float detection for text inputs.
|
||||
"""
|
||||
html = '<form><input type="text" name="rate" value="3.14" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"rate": 3.14}, \
|
||||
f"Expected {{'rate': 3.14}}, got {form.fields}"
|
||||
assert isinstance(form.fields["rate"], float), \
|
||||
f"Expected float type, got {type(form.fields['rate'])}"
|
||||
|
||||
def test_i_can_handle_text_input_with_boolean_true(self, mock_client):
|
||||
"""
|
||||
Test that a text input with 'true' is converted to bool.
|
||||
|
||||
This ensures boolean keyword detection for text inputs.
|
||||
"""
|
||||
html = '<form><input type="text" name="flag" value="true" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"flag": True}, \
|
||||
f"Expected {{'flag': True}}, got {form.fields}"
|
||||
assert isinstance(form.fields["flag"], bool), \
|
||||
f"Expected bool type, got {type(form.fields['flag'])}"
|
||||
|
||||
def test_i_can_handle_text_input_with_boolean_false(self, mock_client):
|
||||
"""
|
||||
Test that a text input with 'false' is converted to bool.
|
||||
|
||||
This ensures boolean keyword detection for false values.
|
||||
"""
|
||||
html = '<form><input type="text" name="flag" value="false" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"flag": False}, \
|
||||
f"Expected {{'flag': False}}, got {form.fields}"
|
||||
assert isinstance(form.fields["flag"], bool), \
|
||||
f"Expected bool type, got {type(form.fields['flag'])}"
|
||||
|
||||
def test_i_can_handle_hidden_input_with_auto_conversion(self, mock_client):
|
||||
"""
|
||||
Test that hidden inputs benefit from automatic type conversion.
|
||||
|
||||
This ensures hidden fields are processed like text fields.
|
||||
"""
|
||||
html = '<form><input type="hidden" name="id" value="42" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"id": 42}, \
|
||||
f"Expected {{'id': 42}}, got {form.fields}"
|
||||
assert isinstance(form.fields["id"], int), \
|
||||
f"Expected int type, got {type(form.fields['id'])}"
|
||||
|
||||
def test_i_can_handle_empty_input_value(self, mock_client):
|
||||
"""
|
||||
Test that an empty input value remains an empty string.
|
||||
|
||||
This ensures empty values are not converted to None or other types.
|
||||
"""
|
||||
html = '<form><input type="text" name="optional" value="" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"optional": ""}, \
|
||||
f"Expected {{'optional': ''}}, got {form.fields}"
|
||||
assert isinstance(form.fields["optional"], str), \
|
||||
f"Expected str type, got {type(form.fields['optional'])}"
|
||||
|
||||
def test_i_can_extract_select_options(self, mock_client):
|
||||
"""
|
||||
Test that select options are correctly extracted.
|
||||
|
||||
This ensures proper population of select_fields dictionary.
|
||||
"""
|
||||
html = '''
|
||||
<form>
|
||||
<select name="country">
|
||||
<option value="FR">France</option>
|
||||
<option value="US">USA</option>
|
||||
</select>
|
||||
</form>
|
||||
'''
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
expected_options = [
|
||||
{"value": "FR", "text": "France"},
|
||||
{"value": "US", "text": "USA"}
|
||||
]
|
||||
|
||||
assert form.select_fields == {"country": expected_options}, \
|
||||
f"Expected {{'country': {expected_options}}}, got {form.select_fields}"
|
||||
|
||||
def test_i_can_handle_select_with_selected_option(self, mock_client):
|
||||
"""
|
||||
Test that the selected option is stored in fields.
|
||||
|
||||
This ensures proper detection of selected options.
|
||||
"""
|
||||
html = '''
|
||||
<form>
|
||||
<select name="country">
|
||||
<option value="FR">France</option>
|
||||
<option value="US" selected>USA</option>
|
||||
</select>
|
||||
</form>
|
||||
'''
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"country": "US"}, \
|
||||
f"Expected {{'country': 'US'}}, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_select_without_selected_option(self, mock_client):
|
||||
"""
|
||||
Test that the first option is used by default.
|
||||
|
||||
This ensures proper default value handling for select fields.
|
||||
"""
|
||||
html = '''
|
||||
<form>
|
||||
<select name="country">
|
||||
<option value="FR">France</option>
|
||||
<option value="US">USA</option>
|
||||
</select>
|
||||
</form>
|
||||
'''
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"country": "FR"}, \
|
||||
f"Expected {{'country': 'FR'}}, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_select_option_without_value_attribute(self, mock_client):
|
||||
"""
|
||||
Test that option text is used when value attribute is missing.
|
||||
|
||||
This ensures fallback to text content for options without value.
|
||||
"""
|
||||
html = '''
|
||||
<form>
|
||||
<select name="country">
|
||||
<option>France</option>
|
||||
<option>USA</option>
|
||||
</select>
|
||||
</form>
|
||||
'''
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
expected_options = [
|
||||
{"value": "France", "text": "France"},
|
||||
{"value": "USA", "text": "USA"}
|
||||
]
|
||||
|
||||
assert form.select_fields == {"country": expected_options}, \
|
||||
f"Expected {{'country': {expected_options}}}, got {form.select_fields}"
|
||||
|
||||
def test_i_can_handle_mixed_input_types_in_same_form(self, mock_client):
|
||||
"""
|
||||
Test that all input types work together correctly.
|
||||
|
||||
This is a comprehensive test ensuring type handling is consistent.
|
||||
"""
|
||||
html = '''
|
||||
<form>
|
||||
<input type="text" name="username" value="john" />
|
||||
<input type="number" name="age" value="30" />
|
||||
<input type="checkbox" name="subscribe" checked />
|
||||
<input type="radio" name="gender" value="male" checked />
|
||||
<input type="hidden" name="token" value="123" />
|
||||
<select name="country">
|
||||
<option value="FR" selected>France</option>
|
||||
<option value="US">USA</option>
|
||||
</select>
|
||||
</form>
|
||||
'''
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
expected_fields = {
|
||||
"username": "john",
|
||||
"age": 30,
|
||||
"subscribe": True,
|
||||
"gender": "male",
|
||||
"token": 123,
|
||||
"country": "FR"
|
||||
}
|
||||
|
||||
assert form.fields == expected_fields, \
|
||||
f"Expected {expected_fields}, got {form.fields}"
|
||||
|
||||
assert "country" in form.select_fields, \
|
||||
f"Expected 'country' in select_fields, got {form.select_fields}"
|
||||
|
||||
def test_i_can_handle_input_without_name_attribute(self, mock_client):
|
||||
"""
|
||||
Test that inputs without name attribute are ignored.
|
||||
|
||||
This ensures proper handling of unnamed inputs.
|
||||
"""
|
||||
html = '<form><input type="text" value="test" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {}, \
|
||||
f"Expected empty dict, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_select_without_name_attribute(self, mock_client):
|
||||
"""
|
||||
Test that select elements without name attribute are ignored.
|
||||
|
||||
This ensures proper handling of unnamed select fields.
|
||||
"""
|
||||
html = '<form><select><option>Test</option></select></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.select_fields == {}, \
|
||||
f"Expected empty dict, got {form.select_fields}"
|
||||
assert form.fields == {}, \
|
||||
f"Expected empty dict, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_number_input_with_empty_value(self, mock_client):
|
||||
"""
|
||||
Test that a number input with empty value remains empty string.
|
||||
|
||||
This ensures empty values are not converted to 0 or None.
|
||||
"""
|
||||
html = '<form><input type="number" name="count" value="" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"count": ""}, \
|
||||
f"Expected {{'count': ''}}, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_select_with_empty_options(self, mock_client):
|
||||
"""
|
||||
Test behavior with a select element without options.
|
||||
|
||||
This ensures robustness when dealing with empty selects.
|
||||
"""
|
||||
html = '<form><select name="empty"></select></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.select_fields == {"empty": []}, \
|
||||
f"Expected {{'empty': []}}, got {form.select_fields}"
|
||||
assert "empty" not in form.fields, \
|
||||
f"Expected 'empty' not in fields, got {form.fields}"
|
||||
|
||||
def test_i_can_handle_case_insensitive_boolean_values(self, mock_client):
|
||||
"""
|
||||
Test that boolean values are case-insensitive.
|
||||
|
||||
This ensures 'TRUE', 'True', 'FALSE', 'False' are all converted properly.
|
||||
"""
|
||||
html = '<form><input type="text" name="flag" value="TRUE" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
assert form.fields == {"flag": True}, \
|
||||
f"Expected {{'flag': True}}, got {form.fields}"
|
||||
assert isinstance(form.fields["flag"], bool), \
|
||||
f"Expected bool type, got {type(form.fields['flag'])}"
|
||||
|
||||
|
||||
class TestMyTestClientFill:
|
||||
def test_i_can_fill_form_using_input_name(self, mock_client):
|
||||
"""
|
||||
I can fill using the input name
|
||||
"""
|
||||
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
form.fill(username="john_doe")
|
||||
|
||||
assert form.fields == {"username": "john_doe"}
|
||||
|
||||
def test_i_can_fill_form_using_label(self, mock_client):
|
||||
"""
|
||||
I can fill using the label associated with the input
|
||||
"""
|
||||
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
form.fill(Username="john_doe")
|
||||
|
||||
assert form.fields == {"username": "john_doe"}
|
||||
|
||||
def test_i_cannot_fill_form_with_invalid_field_name(self, mock_client):
|
||||
"""
|
||||
I cannot fill form with invalid field name
|
||||
"""
|
||||
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
|
||||
form = TestableForm(mock_client, html)
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
form.fill(invalid_field="john_doe")
|
||||
|
||||
assert str(excinfo.value) == "Invalid field name 'invalid_field'."
|
||||
|
||||
|
||||
class TestableFormSubmit:
|
||||
"""
|
||||
Test suite for the submit() method of TestableForm class.
|
||||
This module tests form submission for both HTMX and classic forms.
|
||||
"""
|
||||
|
||||
def test_i_can_submit_classic_form_with_post_method(self):
|
||||
"""
|
||||
Test that a classic form with POST method is submitted correctly.
|
||||
|
||||
This ensures the form uses the action and method attributes properly.
|
||||
"""
|
||||
# HTML form with classic submission
|
||||
html = '''
|
||||
<form action="/submit" method="post">
|
||||
<input type="text" name="username" value="john_doe" />
|
||||
<input type="password" name="password" value="secret123" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
# Create the form
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
@rt('/submit')
|
||||
def post(username: str, password: str):
|
||||
return f"Form received {username=}, {password=}"
|
||||
|
||||
form.submit()
|
||||
assert client.get_content() == "Form received username='john_doe', password='secret123'"
|
||||
|
||||
def test_i_can_submit_classic_form_with_get_method(self):
|
||||
"""
|
||||
Test that a classic form with GET method is submitted correctly.
|
||||
|
||||
This ensures GET requests are properly handled.
|
||||
"""
|
||||
html = '''
|
||||
<form action="/search" method="get">
|
||||
<input type="text" name="q" value="python" />
|
||||
<input type="text" name="page" value="1" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
@rt('/search')
|
||||
def get(q: str, page: int):
|
||||
return f"Search results for {q=}, {page=}"
|
||||
|
||||
form.submit()
|
||||
assert client.get_content() == "Search results for q='python', page=1"
|
||||
|
||||
def test_i_can_submit_classic_form_without_method_defaults_to_post(self):
|
||||
"""
|
||||
Test that POST is used by default when method attribute is absent.
|
||||
|
||||
This ensures proper default behavior matching HTML standards.
|
||||
"""
|
||||
html = '''
|
||||
<form action="/submit">
|
||||
<input type="text" name="data" value="test" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
@rt('/submit')
|
||||
def post(data: str):
|
||||
return f"Received {data=}"
|
||||
|
||||
form.submit()
|
||||
assert client.get_content() == "Received data='test'"
|
||||
|
||||
def test_i_can_submit_form_with_htmx_post(self):
|
||||
"""
|
||||
Test that a form with hx_post uses HTMX submission.
|
||||
|
||||
This ensures HTMX-enabled forms are handled correctly.
|
||||
"""
|
||||
html = '''
|
||||
<form hx-post="/htmx-submit">
|
||||
<input type="text" name="username" value="alice" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
@rt('/htmx-submit')
|
||||
def post(username: str):
|
||||
return f"HTMX received {username=}"
|
||||
|
||||
form.submit()
|
||||
assert client.get_content() == "HTMX received username='alice'"
|
||||
|
||||
def test_i_can_submit_form_with_htmx_get(self):
|
||||
"""
|
||||
Test that a form with hx_get uses HTMX submission.
|
||||
|
||||
This ensures HTMX GET requests work properly.
|
||||
"""
|
||||
html = '''
|
||||
<form hx-get="/htmx-search">
|
||||
<input type="text" name="query" value="fasthtml" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
@rt('/htmx-search')
|
||||
def get(query: str):
|
||||
return f"HTMX search for {query=}"
|
||||
|
||||
form.submit()
|
||||
assert client.get_content() == "HTMX search for query='fasthtml'"
|
||||
|
||||
def test_i_can_submit_form_with_filled_fields(self):
|
||||
"""
|
||||
Test that fields filled via fill_form() are submitted correctly.
|
||||
|
||||
This ensures dynamic form filling works as expected.
|
||||
"""
|
||||
html = '''
|
||||
<form action="/login" method="post">
|
||||
<input type="text" name="username" value="" />
|
||||
<input type="password" name="password" value="" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
# Fill the form dynamically
|
||||
form.fill(username="bob", password="secure456")
|
||||
|
||||
@rt('/login')
|
||||
def post(username: str, password: str):
|
||||
return f"Login {username=}, {password=}"
|
||||
|
||||
form.submit()
|
||||
assert client.get_content() == "Login username='bob', password='secure456'"
|
||||
|
||||
def test_i_can_submit_form_with_mixed_field_types(self):
|
||||
"""
|
||||
Test that all field types are submitted with correct values.
|
||||
|
||||
This ensures type conversion and submission work together.
|
||||
"""
|
||||
html = '''
|
||||
<form action="/register" method="post">
|
||||
<input type="text" name="username" value="charlie" />
|
||||
<input type="number" name="age" value="30" />
|
||||
<input type="checkbox" name="newsletter" checked />
|
||||
<select name="country">
|
||||
<option value="US" selected>USA</option>
|
||||
<option value="FR">France</option>
|
||||
</select>
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
@rt('/register')
|
||||
def post(username: str, age: int, newsletter: bool, country: str):
|
||||
return f"Registration {username=}, {age=}, {newsletter=}, {country=}"
|
||||
|
||||
result = form.submit()
|
||||
|
||||
# Note: the types are converted in self.fields
|
||||
expected = "Registration username='charlie', age=30, newsletter=True, country='US'"
|
||||
assert client.get_content() == expected
|
||||
|
||||
def test_i_cannot_submit_classic_form_without_action(self):
|
||||
"""
|
||||
Test that an exception is raised when action attribute is missing.
|
||||
|
||||
This ensures proper error handling for malformed forms.
|
||||
"""
|
||||
html = '''
|
||||
<form method="post">
|
||||
<input type="text" name="data" value="test" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
# Should raise ValueError
|
||||
try:
|
||||
form.submit()
|
||||
assert False, "Expected ValueError to be raised"
|
||||
except ValueError as e:
|
||||
assert "no 'action' attribute" in str(e).lower()
|
||||
|
||||
def test_i_cannot_submit_classic_form_with_empty_action(self):
|
||||
"""
|
||||
Test that an exception is raised when action attribute is empty.
|
||||
|
||||
This ensures validation of the action attribute.
|
||||
"""
|
||||
html = '''
|
||||
<form action="" method="post">
|
||||
<input type="text" name="data" value="test" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
# Should raise ValueError
|
||||
try:
|
||||
form.submit()
|
||||
assert False, "Expected ValueError to be raised"
|
||||
except ValueError as e:
|
||||
assert "no 'action' attribute" in str(e).lower()
|
||||
|
||||
def test_i_can_submit_form_with_case_insensitive_method(self):
|
||||
"""
|
||||
Test that HTTP method is properly normalized to uppercase.
|
||||
|
||||
This ensures method attribute is case-insensitive.
|
||||
"""
|
||||
html = '''
|
||||
<form action="/submit" method="PoSt">
|
||||
<input type="text" name="data" value="test" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
@rt('/submit')
|
||||
def post(data: str):
|
||||
return f"Received {data=}"
|
||||
|
||||
form.submit()
|
||||
assert client.get_content() == "Received data='test'"
|
||||
|
||||
def test_i_can_prioritize_htmx_over_classic_submission(self):
|
||||
"""
|
||||
Test that HTMX is prioritized even when action/method are present.
|
||||
|
||||
This ensures correct priority between HTMX and classic submission.
|
||||
"""
|
||||
html = '''
|
||||
<form action="/classic" method="post" hx-post="/htmx">
|
||||
<input type="text" name="data" value="test" />
|
||||
</form>
|
||||
'''
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
@rt('/classic')
|
||||
def post_classic(data: str):
|
||||
return "Classic submission"
|
||||
|
||||
@rt('/htmx')
|
||||
def post_htmx(data: str):
|
||||
return "HTMX submission"
|
||||
|
||||
form.submit()
|
||||
assert client.get_content() == "HTMX submission"
|
||||
|
||||
def test_i_can_submit_empty_form(self):
|
||||
"""
|
||||
Test that a form without fields can be submitted.
|
||||
|
||||
This ensures robustness for minimal forms.
|
||||
"""
|
||||
html = '<form action="/empty" method="post"></form>'
|
||||
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
client = MyTestClient(test_app)
|
||||
form = TestableForm(client, html)
|
||||
|
||||
@rt('/empty')
|
||||
def post():
|
||||
return "Empty form received"
|
||||
|
||||
form.submit()
|
||||
assert client.get_content() == "Empty form received"
|
||||
Reference in New Issue
Block a user