Initial commit
This commit is contained in:
168
tests/test_crud_dynamic.py
Normal file
168
tests/test_crud_dynamic.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Dynamic CRUD Tests
|
||||
==================
|
||||
Auto-discovers all POST routes and generates valid form data from
|
||||
introspected Form() field signatures. No hardcoded form payloads.
|
||||
|
||||
When you add a new entity router with standard CRUD, these tests
|
||||
automatically cover create/edit/delete on next run.
|
||||
|
||||
Tests (run order matters - action before delete to preserve seed data):
|
||||
- All POST /create routes accept valid form data and redirect 303
|
||||
- All POST /{id}/edit routes accept valid form data and redirect 303
|
||||
- All POST action routes don't crash (303 or other non-500)
|
||||
- All POST /{id}/delete routes redirect 303
|
||||
- Verify create persists: create then check list page
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from tests.registry import ALL_ROUTES, resolve_path
|
||||
from tests.introspect import RouteKind
|
||||
from tests.form_factory import build_form_data, build_edit_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collect POST routes by kind
|
||||
# ---------------------------------------------------------------------------
|
||||
_CREATE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.CREATE and not r.has_file_upload]
|
||||
_EDIT_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.EDIT and not r.has_file_upload]
|
||||
_DELETE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.DELETE]
|
||||
# Admin trash actions are excluded here — covered by TestAdminTrashLifecycle in test_business_logic.py
|
||||
_ADMIN_TRASH_PATHS = {
|
||||
"/admin/trash/empty",
|
||||
"/admin/trash/{table}/{item_id}/permanent-delete",
|
||||
"/admin/trash/{table}/{item_id}/restore",
|
||||
}
|
||||
_ACTION_ROUTES = [r for r in ALL_ROUTES if r.kind in (RouteKind.ACTION, RouteKind.TOGGLE) and r.path not in _ADMIN_TRASH_PATHS]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create: POST /entity/create with auto-generated form data -> 303
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
_CREATE_ROUTES,
|
||||
ids=[f"CREATE {r.path}" for r in _CREATE_ROUTES],
|
||||
)
|
||||
async def test_create_redirects(client: AsyncClient, all_seeds: dict, route):
|
||||
"""POST to create routes with valid form data should redirect 303."""
|
||||
form_data = build_form_data(route.form_fields, all_seeds)
|
||||
if not form_data:
|
||||
pytest.skip(f"No form fields discovered for {route.path}")
|
||||
|
||||
r = await client.post(route.path, data=form_data, follow_redirects=False)
|
||||
assert r.status_code in (303, 302, 307), (
|
||||
f"POST {route.path} returned {r.status_code} with data {form_data}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edit: POST /entity/{id}/edit with auto-generated form data -> 303
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
_EDIT_ROUTES,
|
||||
ids=[f"EDIT {r.path}" for r in _EDIT_ROUTES],
|
||||
)
|
||||
async def test_edit_redirects(client: AsyncClient, all_seeds: dict, route):
|
||||
"""POST to edit routes with valid form data should redirect 303."""
|
||||
resolved = resolve_path(route.path, all_seeds)
|
||||
if "{" in resolved:
|
||||
pytest.skip(f"No seed data mapping for {route.path}")
|
||||
|
||||
form_data = build_edit_data(route.form_fields, all_seeds)
|
||||
if not form_data:
|
||||
pytest.skip(f"No form fields discovered for {route.path}")
|
||||
|
||||
r = await client.post(resolved, data=form_data, follow_redirects=False)
|
||||
assert r.status_code in (303, 302, 307), (
|
||||
f"POST {resolved} returned {r.status_code} with data {form_data}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action routes: POST /entity/{id}/toggle, etc. -> non-500
|
||||
# (Runs BEFORE delete tests to ensure seed data is intact)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
_ACTION_ROUTES,
|
||||
ids=[f"ACTION {r.path}" for r in _ACTION_ROUTES],
|
||||
)
|
||||
async def test_action_does_not_crash(client: AsyncClient, all_seeds: dict, route):
|
||||
"""POST action routes should not return 500."""
|
||||
resolved = resolve_path(route.path, all_seeds)
|
||||
if "{" in resolved:
|
||||
pytest.skip(f"No seed data mapping for {route.path}")
|
||||
|
||||
form_data = build_form_data(route.form_fields, all_seeds) if route.form_fields else {}
|
||||
r = await client.post(resolved, data=form_data, follow_redirects=False)
|
||||
|
||||
assert r.status_code != 500, (
|
||||
f"POST {resolved} returned 500 (server error)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete: POST /entity/{id}/delete -> 303
|
||||
# (Runs AFTER action tests so seed data is intact for actions)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
_DELETE_ROUTES,
|
||||
ids=[f"DELETE {r.path}" for r in _DELETE_ROUTES],
|
||||
)
|
||||
async def test_delete_redirects(client: AsyncClient, all_seeds: dict, route):
|
||||
"""POST to delete routes should redirect 303."""
|
||||
resolved = resolve_path(route.path, all_seeds)
|
||||
if "{" in resolved:
|
||||
pytest.skip(f"No seed data mapping for {route.path}")
|
||||
|
||||
r = await client.post(resolved, follow_redirects=False)
|
||||
assert r.status_code in (303, 302, 307, 404), (
|
||||
f"POST {resolved} returned {r.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify create actually persists: create then check list page
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
[r for r in _CREATE_ROUTES if r.prefix in ("/domains", "/contacts", "/meetings")],
|
||||
ids=[f"PERSIST {r.path}" for r in _CREATE_ROUTES if r.prefix in ("/domains", "/contacts", "/meetings")],
|
||||
)
|
||||
async def test_create_persists_in_list(client: AsyncClient, all_seeds: dict, route):
|
||||
"""Items created via POST should appear on the list page."""
|
||||
form_data = build_form_data(route.form_fields, all_seeds)
|
||||
if not form_data:
|
||||
pytest.skip(f"No form fields for {route.path}")
|
||||
|
||||
# Use a unique name to search for
|
||||
marker = f"AutoTest_{uuid.uuid4().hex[:8]}"
|
||||
for key in ("name", "title", "first_name"):
|
||||
if key in form_data:
|
||||
form_data[key] = marker
|
||||
break
|
||||
else:
|
||||
pytest.skip(f"No name/title field found for {route.path}")
|
||||
|
||||
await client.post(route.path, data=form_data, follow_redirects=False)
|
||||
|
||||
# Check the list page
|
||||
list_path = route.prefix + "/"
|
||||
r = await client.get(list_path)
|
||||
assert marker in r.text, (
|
||||
f"Created item '{marker}' not found on {list_path}"
|
||||
)
|
||||
Reference in New Issue
Block a user