5 Commits

Author SHA1 Message Date
kodjo 96c26d0ead I can register, login, and logout 2025-10-27 22:17:21 +01:00
kodjo 3f3e3a6ae5 I can login test is now working 2025-10-27 21:47:07 +01:00
kodjo 09d012d065 Working on LoginPage tests 2025-10-26 22:45:34 +01:00
kodjo b98e52378e Adding test for LoginPage 2025-10-26 20:26:32 +01:00
kodjo f39205dba0 I can register and login (neeed more tests) 2025-10-26 17:00:00 +01:00
27 changed files with 3336 additions and 337 deletions
+1
View File
@@ -22,6 +22,7 @@ tools.db
.idea/sqldialects.xml
.idea_bak
**/*.prof
**/*.db
# Created by .ignore support plugin (hsz.mobi)
### Python template
+10 -1
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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
+283
View File
@@ -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!
View File
@@ -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()
+125
View File
@@ -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"
)
+185
View File
@@ -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()
+195
View File
@@ -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()
+302
View File
@@ -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
+392 -6
View File
@@ -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):
View File
+115
View File
@@ -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")
View File
+33
View File
@@ -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")
-326
View File
@@ -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()."
View File
+484
View File
@@ -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
+922
View File
@@ -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"