Files
lifeos-dev/tests/form_factory.py

203 lines
6.1 KiB
Python

"""
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"),
]
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