Initial commit
This commit is contained in:
204
tests/form_factory.py
Normal file
204
tests/form_factory.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Form Data Factory
|
||||
=================
|
||||
Generates valid POST form data for any route, using:
|
||||
1. Introspected Form field names/types from the route
|
||||
2. Seed data UUIDs for FK references (domain_id, project_id, etc.)
|
||||
3. Heuristic value generation based on field name patterns
|
||||
|
||||
This eliminates hardcoded form data in tests. When a route's
|
||||
form fields change, tests automatically adapt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from tests.introspect import FormField
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field name -> value resolution rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# FK fields map to seed fixture keys
|
||||
FK_FIELD_MAP = {
|
||||
"domain_id": "domain",
|
||||
"area_id": "area",
|
||||
"project_id": "project",
|
||||
"task_id": "task",
|
||||
"folder_id": "weblink_folder",
|
||||
"parent_id": None, # Usually optional, skip
|
||||
"meeting_id": None,
|
||||
"contact_id": "contact",
|
||||
"release_id": None,
|
||||
"note_id": "note",
|
||||
"list_id": "list",
|
||||
"process_id": "process",
|
||||
"run_id": "process_run",
|
||||
}
|
||||
|
||||
# Field name pattern -> static test value
|
||||
NAME_PATTERNS: list[tuple[str, Any]] = [
|
||||
# Exact matches first
|
||||
("title", "Test Title Auto"),
|
||||
("name", "Test Name Auto"),
|
||||
("first_name", "TestFirst"),
|
||||
("last_name", "TestLast"),
|
||||
("description", "Auto-generated test description"),
|
||||
("body", "Auto-generated test body content"),
|
||||
("raw_text", "Auto capture line 1\nAuto capture line 2"),
|
||||
("url", "https://test.example.com"),
|
||||
("email", "autotest@example.com"),
|
||||
("phone", "555-0199"),
|
||||
("company", "Test Corp"),
|
||||
("role", "Tester"),
|
||||
("color", "#AA55CC"),
|
||||
("icon", "star"),
|
||||
("status", "active"),
|
||||
("priority", "3"),
|
||||
("content_format", "rich"),
|
||||
("meeting_date", None), # Resolved dynamically
|
||||
("start_date", None),
|
||||
("end_date", None),
|
||||
("start_time", None),
|
||||
("end_time", None),
|
||||
("due_date", None),
|
||||
("start_at", None),
|
||||
("end_at", None),
|
||||
("focus_date", None),
|
||||
("sort_order", "0"),
|
||||
("estimated_minutes", "30"),
|
||||
("energy_required", "medium"),
|
||||
("context", ""),
|
||||
("recurrence", ""),
|
||||
("version_label", "v1.0"),
|
||||
("agenda", "Test agenda"),
|
||||
("notes_body", "Test notes"),
|
||||
("location", "Test Location"),
|
||||
("tags", ""),
|
||||
("notes", "Test notes field"),
|
||||
("weekly_hours", "10"),
|
||||
("effective_from", None), # Resolved dynamically as date
|
||||
]
|
||||
|
||||
|
||||
def _resolve_date_field(field_name: str) -> str:
|
||||
"""Generate appropriate date/time string based on field name."""
|
||||
now = datetime.now(timezone.utc)
|
||||
if "time" in field_name and "date" not in field_name:
|
||||
return now.strftime("%H:%M")
|
||||
if "date" in field_name:
|
||||
return date.today().isoformat()
|
||||
if field_name in ("start_at", "end_at"):
|
||||
return now.isoformat()
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
def build_form_data(
|
||||
form_fields: list[FormField],
|
||||
seed_data: dict[str, dict] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Build a valid form data dict for a POST request.
|
||||
|
||||
Args:
|
||||
form_fields: List of FormField from route introspection.
|
||||
seed_data: Dict mapping entity type to seed fixture dict.
|
||||
e.g. {"domain": {"id": "abc-123", ...}, "project": {"id": "def-456", ...}}
|
||||
|
||||
Returns:
|
||||
Dict of field_name -> string value, ready for httpx POST data.
|
||||
"""
|
||||
seed_data = seed_data or {}
|
||||
data: dict[str, str] = {}
|
||||
|
||||
for field in form_fields:
|
||||
if field.is_file:
|
||||
continue # Skip file uploads in form data
|
||||
|
||||
value = _resolve_field_value(field, seed_data)
|
||||
if value is not None:
|
||||
data[field.name] = str(value)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _resolve_field_value(
|
||||
field: FormField,
|
||||
seed_data: dict[str, dict],
|
||||
) -> Any | None:
|
||||
"""Resolve a single field's test value."""
|
||||
name = field.name
|
||||
|
||||
# 1. FK fields -> look up seed data UUID
|
||||
if name in FK_FIELD_MAP:
|
||||
entity_type = FK_FIELD_MAP[name]
|
||||
if entity_type is None:
|
||||
# Optional FK with no mapping, return None (skip)
|
||||
return "" if not field.required else None
|
||||
if entity_type in seed_data:
|
||||
val = seed_data[entity_type]
|
||||
# Support both flat UUID strings and dict with "id" key
|
||||
if isinstance(val, dict) and "id" in val:
|
||||
return val["id"]
|
||||
elif isinstance(val, str):
|
||||
return val
|
||||
# Required FK but no seed data available
|
||||
return None if not field.required else ""
|
||||
|
||||
# 2. Date/time fields
|
||||
if any(kw in name for kw in ("date", "time", "_at")):
|
||||
return _resolve_date_field(name)
|
||||
|
||||
# 3. Pattern matching on field name
|
||||
for pattern_name, pattern_value in NAME_PATTERNS:
|
||||
if name == pattern_name:
|
||||
if pattern_value is None:
|
||||
return _resolve_date_field(name)
|
||||
return pattern_value
|
||||
|
||||
# 4. Partial name matching for common patterns
|
||||
if "title" in name:
|
||||
return "Test Title Auto"
|
||||
if "name" in name:
|
||||
return "Test Name Auto"
|
||||
if "description" in name or "desc" in name:
|
||||
return "Auto test description"
|
||||
if "url" in name:
|
||||
return "https://test.example.com"
|
||||
if "email" in name:
|
||||
return "auto@test.com"
|
||||
|
||||
# 5. Type-based fallback
|
||||
if "int" in field.annotation.lower():
|
||||
return "0"
|
||||
if "bool" in field.annotation.lower():
|
||||
return "false"
|
||||
|
||||
# 6. If field has a default, use it
|
||||
if field.default is not None:
|
||||
return str(field.default)
|
||||
|
||||
# 7. Last resort for required string fields
|
||||
if field.required:
|
||||
return f"test_{name}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def build_edit_data(
|
||||
form_fields: list[FormField],
|
||||
seed_data: dict[str, dict] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Build form data for an edit/update request.
|
||||
Same as build_form_data but prefixes string values with "Updated "
|
||||
so tests can verify the edit took effect.
|
||||
"""
|
||||
data = build_form_data(form_fields, seed_data)
|
||||
for key, value in data.items():
|
||||
# Only modify display-name fields, not IDs/dates/status
|
||||
if key in ("title", "name", "first_name", "last_name", "description", "body"):
|
||||
data[key] = f"Updated {value}"
|
||||
return data
|
||||
Reference in New Issue
Block a user