tester fully decoupled

This commit is contained in:
2026-04-12 05:50:08 -03:00
parent 85a856b7ac
commit 3e625c2a27
10 changed files with 63 additions and 208 deletions

View File

@@ -271,16 +271,17 @@ def copy_cfg(output_dir: Path, room: str):
# Now in cfg/<room>/soleprint/ # Now in cfg/<room>/soleprint/
room_soleprint = room_cfg / "soleprint" room_soleprint = room_cfg / "soleprint"
if room_soleprint.exists(): if room_soleprint.exists():
systems = {"artery", "atlas", "station"}
for item in room_soleprint.iterdir(): for item in room_soleprint.iterdir():
if item.is_file(): if item.name in systems:
# Merge system dirs into already-copied framework code
log.info(f" Merging {room} {item.name}...")
merge_into(item, output_dir / item.name)
elif item.is_file():
copy_path(item, output_dir / item.name)
elif item.is_dir():
# Copy non-system dirs as-is (nginx/, etc.)
copy_path(item, output_dir / item.name) copy_path(item, output_dir / item.name)
# Merge room-specific system configs from soleprint subfolder
for system in ["artery", "atlas", "station"]:
room_system = room_soleprint / system
if room_system.exists():
log.info(f" Merging {room} {system}...")
merge_into(room_system, output_dir / system)
def build_soleprint(output_dir: Path, room: str): def build_soleprint(output_dir: Path, room: str):

View File

@@ -29,7 +29,7 @@ templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(request: Request): def index(request: Request):
"""Mock configuration UI.""" """Mock configuration UI."""
return templates.TemplateResponse("index.html", {"request": request}) return templates.TemplateResponse(request, "index.html")
# Include router at root (matches real Amar API structure) # Include router at root (matches real Amar API structure)
app.include_router(router) app.include_router(router)

View File

@@ -1,164 +1,4 @@
""" """Re-export from parent — room tests import from here."""
Pure HTTP Contract Tests - Base Class from ..base import ContractTestCase, get_base_url
Framework-agnostic: works against ANY backend implementation. __all__ = ["ContractTestCase", "get_base_url"]
Does NOT manage database - expects a ready environment.
Requirements:
- Server running at CONTRACT_TEST_URL
- Database migrated and seeded
- Test user exists OR CONTRACT_TEST_TOKEN provided
Usage:
CONTRACT_TEST_URL=http://127.0.0.1:8000 pytest
CONTRACT_TEST_TOKEN=your_jwt_token pytest
"""
import os
import unittest
import httpx
from .endpoints import Endpoints
def get_base_url():
"""Get base URL from environment (required)"""
url = os.environ.get("CONTRACT_TEST_URL", "")
if not url:
raise ValueError("CONTRACT_TEST_URL environment variable required")
return url.rstrip("/")
class ContractTestCase(unittest.TestCase):
"""
Base class for pure HTTP contract tests.
Features:
- Framework-agnostic (works with Django, FastAPI, Node, etc.)
- Pure HTTP via requests library
- No database access - all data through API
- JWT authentication
"""
# Auth credentials - override via environment
TEST_USER_EMAIL = os.environ.get("CONTRACT_TEST_USER", "contract_test@example.com")
TEST_USER_PASSWORD = os.environ.get("CONTRACT_TEST_PASSWORD", "testpass123")
# Class-level cache
_base_url = None
_token = None
@classmethod
def setUpClass(cls):
"""Set up once per test class"""
super().setUpClass()
cls._base_url = get_base_url()
# Use provided token or fetch one
cls._token = os.environ.get("CONTRACT_TEST_TOKEN", "")
if not cls._token:
cls._token = cls._fetch_token()
@classmethod
def _fetch_token(cls):
"""Get JWT token for authentication"""
url = f"{cls._base_url}{Endpoints.TOKEN}"
try:
response = httpx.post(url, json={
"username": cls.TEST_USER_EMAIL,
"password": cls.TEST_USER_PASSWORD,
}, timeout=10)
if response.status_code == 200:
return response.json().get("access", "")
else:
print(f"Warning: Token request failed with {response.status_code}")
except httpx.RequestError as e:
print(f"Warning: Token request failed: {e}")
return ""
@property
def base_url(self):
return self._base_url
@property
def token(self):
return self._token
def _auth_headers(self):
"""Get authorization headers"""
if self.token:
return {"Authorization": f"Bearer {self.token}"}
return {}
# =========================================================================
# HTTP helpers
# =========================================================================
def get(self, path: str, params: dict = None, **kwargs):
"""GET request"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.get(url, params=params, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def post(self, path: str, data: dict = None, **kwargs):
"""POST request with JSON"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.post(url, json=data, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def put(self, path: str, data: dict = None, **kwargs):
"""PUT request with JSON"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.put(url, json=data, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def patch(self, path: str, data: dict = None, **kwargs):
"""PATCH request with JSON"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.patch(url, json=data, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def delete(self, path: str, **kwargs):
"""DELETE request"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.delete(url, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def _wrap_response(self, response):
"""Add .data attribute for consistency with DRF responses"""
try:
response.data = response.json()
except Exception:
response.data = None
return response
# =========================================================================
# Assertion helpers
# =========================================================================
def assert_status(self, response, expected_status: int):
"""Assert response has expected status code"""
self.assertEqual(
response.status_code,
expected_status,
f"Expected {expected_status}, got {response.status_code}. "
f"Response: {response.data if hasattr(response, 'data') else response.content[:500]}"
)
def assert_has_fields(self, data: dict, *fields: str):
"""Assert dictionary has all specified fields"""
missing = [f for f in fields if f not in data]
self.assertEqual(missing, [], f"Missing fields: {missing}. Got: {list(data.keys())}")
def assert_is_list(self, data, min_length: int = 0):
"""Assert data is a list with minimum length"""
self.assertIsInstance(data, list)
self.assertGreaterEqual(len(data), min_length)
__all__ = ["ContractTestCase"]

View File

@@ -29,7 +29,7 @@ templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(request: Request): def index(request: Request):
"""Mock configuration UI.""" """Mock configuration UI."""
return templates.TemplateResponse("index.html", {"request": request}) return templates.TemplateResponse(request, "index.html")
# Include router at root (matches real MercadoPago API structure) # Include router at root (matches real MercadoPago API structure)
app.include_router(router) app.include_router(router)

View File

@@ -512,9 +512,17 @@ def station_index(request: Request):
) )
# Mount station tool routers
try:
from station.tools.tester.api import router as tester_router
app.include_router(tester_router, prefix="/station")
except ImportError as e:
print(f"Warning: Could not load tester router: {e}")
@app.get("/station/{path:path}") @app.get("/station/{path:path}")
def station_route(path: str): def station_route(path: str):
"""Station sub-routes.""" """Station sub-routes (fallback)."""
return {"system": "station", "path": path} return {"system": "station", "path": path}

View File

@@ -2,6 +2,7 @@
FastAPI router for tester tool. FastAPI router for tester tool.
""" """
import os
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
@@ -69,8 +70,7 @@ def index(request: Request):
tests_tree = get_tests_tree() tests_tree = get_tests_tree()
tests_list = discover_tests() tests_list = discover_tests()
return templates.TemplateResponse("index.html", { return templates.TemplateResponse(request, "index.html", context={
"request": request,
"config": config, "config": config,
"tests_tree": tests_tree, "tests_tree": tests_tree,
"total_tests": len(tests_list), "total_tests": len(tests_list),
@@ -86,8 +86,7 @@ def health():
@router.get("/filters", response_class=HTMLResponse) @router.get("/filters", response_class=HTMLResponse)
def test_filters(request: Request): def test_filters(request: Request):
"""Show filterable test view with multiple filter options.""" """Show filterable test view with multiple filter options."""
return templates.TemplateResponse("filters.html", { return templates.TemplateResponse(request, "filters.html", context={
"request": request,
"config": config, "config": config,
}) })
@@ -95,8 +94,7 @@ def test_filters(request: Request):
@router.get("/filters_v2", response_class=HTMLResponse) @router.get("/filters_v2", response_class=HTMLResponse)
def test_filters_v2(request: Request): def test_filters_v2(request: Request):
"""Show Gherkin-driven filter view (v2 with pulse variables).""" """Show Gherkin-driven filter view (v2 with pulse variables)."""
return templates.TemplateResponse("filters_v2.html", { return templates.TemplateResponse(request, "filters_v2.html", context={
"request": request,
"config": config, "config": config,
}) })
@@ -140,10 +138,18 @@ def select_environment(env_id: str):
if not env: if not env:
raise HTTPException(status_code=404, detail=f"Environment {env_id} not found") raise HTTPException(status_code=404, detail=f"Environment {env_id} not found")
# Update config (in memory for this session) # Update config and env vars (tests read from os.environ)
config["CONTRACT_TEST_URL"] = env["url"] config["CONTRACT_TEST_URL"] = env["url"]
config["CONTRACT_TEST_API_KEY"] = env.get("api_key", "") config["CONTRACT_TEST_API_KEY"] = env.get("api_key", "")
os.environ["CONTRACT_TEST_URL"] = env["url"]
if env.get("api_key"):
os.environ["CONTRACT_TEST_API_KEY"] = env["api_key"]
os.environ["CONTRACT_TEST_AUTH_TYPE"] = "api-key"
else:
os.environ.pop("CONTRACT_TEST_API_KEY", None)
os.environ["CONTRACT_TEST_AUTH_TYPE"] = "bearer"
return { return {
"success": True, "success": True,
"environment": { "environment": {

View File

@@ -4,7 +4,7 @@
"name": "Local", "name": "Local",
"url": "http://localhost:8000", "url": "http://localhost:8000",
"api_key": "", "api_key": "",
"description": "Local development server", "description": "Local development server (bare-metal)",
"default": true "default": true
} }
] ]

View File

@@ -340,8 +340,8 @@
<div> <div>
<h1>Contract HTTP Tests - Filters</h1> <h1>Contract HTTP Tests - Filters</h1>
<div class="nav-links"> <div class="nav-links">
<a href="/tools/tester/">Runner</a> <a href="/station/tools/tester/">Runner</a>
<a href="/tools/tester/filters" class="active">Filters</a> <a href="/station/tools/tester/filters" class="active">Filters</a>
</div> </div>
</div> </div>
<div style="display: flex; align-items: center; gap: 12px; font-size: 0.875rem; color: #9ca3af;"> <div style="display: flex; align-items: center; gap: 12px; font-size: 0.875rem; color: #9ca3af;">
@@ -450,7 +450,7 @@
// Load tests on page load // Load tests on page load
async function loadTests() { async function loadTests() {
try { try {
const response = await fetch('/tools/tester/api/tests'); const response = await fetch('/station/tools/tester/api/tests');
const data = await response.json(); const data = await response.json();
allTests = data.tests; allTests = data.tests;
@@ -505,12 +505,12 @@
async function loadLastRunResults() { async function loadLastRunResults() {
try { try {
const response = await fetch('/tools/tester/api/runs'); const response = await fetch('/station/tools/tester/api/runs');
const data = await response.json(); const data = await response.json();
if (data.runs && data.runs.length > 0) { if (data.runs && data.runs.length > 0) {
const lastRunId = data.runs[0]; const lastRunId = data.runs[0];
const runResponse = await fetch(`/tools/tester/api/run/${lastRunId}`); const runResponse = await fetch(`/station/tools/tester/api/run/${lastRunId}`);
const runData = await runResponse.json(); const runData = await runResponse.json();
runData.results.forEach(result => { runData.results.forEach(result => {
@@ -774,7 +774,7 @@
const testIds = Array.from(selectedTests); const testIds = Array.from(selectedTests);
try { try {
const response = await fetch('/tools/tester/api/run', { const response = await fetch('/station/tools/tester/api/run', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test_ids: testIds }), body: JSON.stringify({ test_ids: testIds }),
@@ -798,7 +798,7 @@
} }
// Redirect to main runner with filters applied // Redirect to main runner with filters applied
window.location.href = `/tools/tester/?${params.toString()}`; window.location.href = `/station/tools/tester/?${params.toString()}`;
} catch (error) { } catch (error) {
console.error('Failed to start run:', error); console.error('Failed to start run:', error);
alert('Failed to start test run'); alert('Failed to start test run');
@@ -814,7 +814,7 @@
// Load environments // Load environments
async function loadEnvironments() { async function loadEnvironments() {
try { try {
const response = await fetch('/tools/tester/api/environments'); const response = await fetch('/station/tools/tester/api/environments');
const data = await response.json(); const data = await response.json();
const selector = document.getElementById('environmentSelector'); const selector = document.getElementById('environmentSelector');
const currentUrl = document.getElementById('currentUrl'); const currentUrl = document.getElementById('currentUrl');
@@ -835,7 +835,7 @@
selector.addEventListener('change', async (e) => { selector.addEventListener('change', async (e) => {
const envId = e.target.value; const envId = e.target.value;
try { try {
const response = await fetch(`/tools/tester/api/environment/select?env_id=${envId}`, { const response = await fetch(`/station/tools/tester/api/environment/select?env_id=${envId}`, {
method: 'POST' method: 'POST'
}); });
const data = await response.json(); const data = await response.json();

View File

@@ -464,9 +464,9 @@
<span class="version-badge">Beta</span> <span class="version-badge">Beta</span>
</h1> </h1>
<div class="nav-links" style="margin-top: 8px;"> <div class="nav-links" style="margin-top: 8px;">
<a href="/tools/tester/">Runner</a> <a href="/station/tools/tester/">Runner</a>
<a href="/tools/tester/filters">Filters v1</a> <a href="/station/tools/tester/filters">Filters v1</a>
<a href="/tools/tester/filters_v2" class="active">Filters v2</a> <a href="/station/tools/tester/filters_v2" class="active">Filters v2</a>
</div> </div>
</div> </div>
<div style="display: flex; align-items: center; gap: 12px; font-size: 0.875rem; color: #9ca3af;"> <div style="display: flex; align-items: center; gap: 12px; font-size: 0.875rem; color: #9ca3af;">
@@ -704,7 +704,7 @@
// Load environments // Load environments
async function loadEnvironments() { async function loadEnvironments() {
try { try {
const response = await fetch('/tools/tester/api/environments'); const response = await fetch('/station/tools/tester/api/environments');
const data = await response.json(); const data = await response.json();
const selector = document.getElementById('environmentSelector'); const selector = document.getElementById('environmentSelector');
const currentUrl = document.getElementById('currentUrl'); const currentUrl = document.getElementById('currentUrl');
@@ -725,7 +725,7 @@
selector.addEventListener('change', async (e) => { selector.addEventListener('change', async (e) => {
const envId = e.target.value; const envId = e.target.value;
try { try {
const response = await fetch(`/tools/tester/api/environment/select?env_id=${envId}`, { const response = await fetch(`/station/tools/tester/api/environment/select?env_id=${envId}`, {
method: 'POST' method: 'POST'
}); });
const data = await response.json(); const data = await response.json();
@@ -747,10 +747,10 @@
async function loadFeatures() { async function loadFeatures() {
try { try {
// First sync features // First sync features
await fetch('/tools/tester/api/features/sync', { method: 'POST' }); await fetch('/station/tools/tester/api/features/sync', { method: 'POST' });
// Then load them // Then load them
const response = await fetch('/tools/tester/api/features'); const response = await fetch('/station/tools/tester/api/features');
const data = await response.json(); const data = await response.json();
allFeatures = data.features; allFeatures = data.features;
@@ -766,7 +766,7 @@
}); });
// Load tags // Load tags
const tagsResponse = await fetch('/tools/tester/api/features/tags'); const tagsResponse = await fetch('/station/tools/tester/api/features/tags');
const tagsData = await tagsResponse.json(); const tagsData = await tagsResponse.json();
const tagFilters = document.getElementById('tagFilters'); const tagFilters = document.getElementById('tagFilters');
@@ -787,7 +787,7 @@
// Load tests // Load tests
async function loadTests() { async function loadTests() {
try { try {
const response = await fetch('/tools/tester/api/tests'); const response = await fetch('/station/tools/tester/api/tests');
const data = await response.json(); const data = await response.json();
// For now, all tests are backend (until we integrate Playwright discovery) // For now, all tests are backend (until we integrate Playwright discovery)
@@ -1109,7 +1109,7 @@
const testIds = Array.from(selectedTests); const testIds = Array.from(selectedTests);
try { try {
const response = await fetch('/tools/tester/api/run', { const response = await fetch('/station/tools/tester/api/run', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test_ids: testIds }), body: JSON.stringify({ test_ids: testIds }),
@@ -1117,7 +1117,7 @@
const data = await response.json(); const data = await response.json();
// Redirect to runner // Redirect to runner
window.location.href = `/tools/tester/?run=${data.run_id}`; window.location.href = `/station/tools/tester/?run=${data.run_id}`;
} catch (error) { } catch (error) {
console.error('Failed to start run:', error); console.error('Failed to start run:', error);
alert('Failed to start test run'); alert('Failed to start test run');

View File

@@ -402,8 +402,8 @@
<div> <div>
<h1>Contract HTTP Tests</h1> <h1>Contract HTTP Tests</h1>
<div style="display: flex; gap: 12px; margin-top: 8px; font-size: 0.875rem;"> <div style="display: flex; gap: 12px; margin-top: 8px; font-size: 0.875rem;">
<a href="/tools/tester/" style="color: #60a5fa; text-decoration: none; font-weight: 600;">Runner</a> <a href="/station/tools/tester/" style="color: #60a5fa; text-decoration: none; font-weight: 600;">Runner</a>
<a href="/tools/tester/filters" style="color: #60a5fa; text-decoration: none;">Filters</a> <a href="/station/tools/tester/filters" style="color: #60a5fa; text-decoration: none;">Filters</a>
</div> </div>
</div> </div>
<div class="config-info"> <div class="config-info">
@@ -602,7 +602,7 @@
// Load environments // Load environments
async function loadEnvironments() { async function loadEnvironments() {
try { try {
const response = await fetch('/tools/tester/api/environments'); const response = await fetch('/station/tools/tester/api/environments');
const data = await response.json(); const data = await response.json();
const selector = document.getElementById('environmentSelector'); const selector = document.getElementById('environmentSelector');
const currentUrl = document.getElementById('currentUrl'); const currentUrl = document.getElementById('currentUrl');
@@ -627,7 +627,7 @@
selector.addEventListener('change', async (e) => { selector.addEventListener('change', async (e) => {
const envId = e.target.value; const envId = e.target.value;
try { try {
const response = await fetch(`/tools/tester/api/environment/select?env_id=${envId}`, { const response = await fetch(`/station/tools/tester/api/environment/select?env_id=${envId}`, {
method: 'POST' method: 'POST'
}); });
const data = await response.json(); const data = await response.json();
@@ -709,7 +709,7 @@
document.getElementById('resultsList').innerHTML = ''; document.getElementById('resultsList').innerHTML = '';
try { try {
const response = await fetch('/tools/tester/api/run', { const response = await fetch('/station/tools/tester/api/run', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ test_ids: testIds }), body: JSON.stringify({ test_ids: testIds }),
@@ -731,7 +731,7 @@
if (!currentRunId) return; if (!currentRunId) return;
try { try {
const response = await fetch(`/tools/tester/api/run/${currentRunId}`); const response = await fetch(`/station/tools/tester/api/run/${currentRunId}`);
const data = await response.json(); const data = await response.json();
updateUI(data); updateUI(data);