Path: blob/master/src/python/cocalc-api/tests/conftest.py
5581 views
"""1Pytest configuration and fixtures for cocalc-api tests.2"""3import json4import os5import time6import uuid7import pytest89from cocalc_api import Hub, Project1011from psycopg2 import pool as pg_pool12from typing import Callable, TypeVar1314# Database configuration examples (DRY principle)15PGHOST_SOCKET_EXAMPLE = "/path/to/cocalc-data/socket"16PGHOST_NETWORK_EXAMPLE = "localhost"1718T = TypeVar('T')192021def retry_with_backoff(22func: Callable[[], T],23max_retries: int = 3,24retry_delay: int = 5,25error_condition: Callable[[RuntimeError],26bool] = lambda e: any(keyword in str(e).lower() for keyword in ["timeout", "closed", "connection", "reset", "broken"]),27) -> T:28"""29Retry a function call with exponential backoff for timeout and connection errors.3031This helper is useful for operations that may timeout or fail on first attempt due to32cold starts (e.g., kernel launches) or transient connection issues.3334Args:35func: Callable that performs the operation36max_retries: Maximum number of attempts (default: 3)37retry_delay: Delay in seconds between retries (default: 5)38error_condition: Function to determine if an error should trigger retry.39Defaults to checking for timeout/connection-related keywords.4041Returns:42The result of the function call4344Raises:45RuntimeError: If all retries fail or error condition doesn't match46"""47for attempt in range(max_retries):48try:49return func()50except RuntimeError as e:51error_msg = str(e).lower()52is_retryable = error_condition(e)53if is_retryable and attempt < max_retries - 1:54print(f"Attempt {attempt + 1} failed ({error_msg[:50]}...), retrying in {retry_delay}s...")55time.sleep(retry_delay)56else:57raise5859# This should never be reached due to the loop, but mypy needs this60raise RuntimeError("Retry loop exhausted without returning")616263def assert_valid_uuid(value, description="value"):64"""65Assert that the given value is a string and a valid UUID.6667Args:68value: The value to check69description: Description of the value for error messages70"""71assert isinstance(value, str), f"{description} should be a string, got {type(value)}"72assert len(value) > 0, f"{description} should not be empty"7374try:75uuid.UUID(value)76except ValueError:77pytest.fail(f"{description} should be a valid UUID, got: {value}")787980def cleanup_project(hub, project_id):81"""82Clean up a test project by stopping it and deleting it.8384Args:85hub: Hub client instance86project_id: Project ID to cleanup87"""88try:89hub.projects.stop(project_id)90except Exception as e:91print(f"Warning: Failed to stop project {project_id}: {e}")9293try:94hub.projects.delete(project_id)95except Exception as e:96print(f"Warning: Failed to delete project {project_id}: {e}")979899@pytest.fixture(scope="session")100def api_key():101"""Get API key from environment variable."""102key = os.environ.get("COCALC_API_KEY")103if not key:104pytest.fail("COCALC_API_KEY environment variable is required but not set")105return key106107108@pytest.fixture(scope="session")109def cocalc_host():110"""Get CoCalc host from environment variable, default to localhost:5000."""111return os.environ.get("COCALC_HOST", "http://localhost:5000")112113114@pytest.fixture(scope="session")115def hub(api_key, cocalc_host):116"""Create Hub client instance."""117return Hub(api_key=api_key, host=cocalc_host)118119120@pytest.fixture(scope="session")121def validate_api_key_config(hub):122"""123Validate that the API key is properly configured for testing.124125For account-scoped keys, requires COCALC_PROJECT_ID to be set.126For project-scoped keys, no additional configuration needed.127"""128scope = None129hub_error = None130131# First, try the hub endpoint (works only for account-scoped keys)132try:133scope = hub.system.test()134except Exception as e:135hub_error = e136137# If hub check failed, fall back to the project endpoint so project-scoped keys work138if scope is None:139try:140project_client = Project(141api_key=hub.api_key,142host=hub.host,143project_id=os.environ.get("COCALC_PROJECT_ID"),144)145scope = project_client.system.test()146except Exception as project_error:147pytest.fail(148"Failed to determine API key scope using both hub and project endpoints:\n"149f" hub error: {hub_error}\n"150f" project error: {project_error}"151)152153is_account_scoped = "account_id" in scope154is_project_scoped = "project_id" in scope155156if is_account_scoped:157# Account-scoped key requires COCALC_PROJECT_ID for project tests158project_id = os.environ.get("COCALC_PROJECT_ID")159if not project_id:160pytest.fail("Account-scoped API key detected, but COCALC_PROJECT_ID is not set.\n\n"161"For testing with an account-scoped key, you must provide a project ID:\n"162" export COCALC_PROJECT_ID=<your-project-uuid>\n\n"163"Alternatively, use a project-scoped API key which has the project ID embedded.")164elif not is_project_scoped:165pytest.fail(f"Could not determine API key scope. Response: {scope}\n"166"Expected either 'account_id' (account-scoped) or 'project_id' (project-scoped).")167168169@pytest.fixture(scope="session")170def temporary_project(hub, resource_tracker, request, validate_api_key_config):171"""172Create a temporary project for testing and return project info.173Uses a session-scoped fixture so only ONE project is created for the entire test suite.174"""175# Create a project with a timestamp to make it unique and identifiable176timestamp = time.strftime("%Y%m%d-%H%M%S")177title = f"CoCalc API Test {timestamp}"178description = "Temporary project created by cocalc-api tests"179180# Use tracked creation181project_id = create_tracked_project(hub, resource_tracker, title=title, description=description)182183# Start the project so it can respond to API calls184try:185hub.projects.start(project_id)186187# Wait for project to be ready (can take 10-15 seconds)188from cocalc_api import Project189190test_project = Project(project_id=project_id, api_key=hub.api_key, host=hub.host)191for attempt in range(10):192time.sleep(5) # Wait 5 seconds before checking193try:194# Try to ping the project to see if it's ready195test_project.system.ping() # If this succeeds, project is ready196break197except Exception:198if attempt == 9: # Last attempt199print(f"Warning: Project {project_id} did not become ready within 50 seconds")200else:201print(f"Warning: Project {project_id} may not be ready yet")202203ensure_python3_kernel(test_project)204205except Exception as e:206print(f"Warning: Failed to start project {project_id}: {e}")207208project_info = {'project_id': project_id, 'title': title, 'description': description}209210# Note: No finalizer needed - cleanup happens automatically via cleanup_all_test_resources211212return project_info213214215@pytest.fixture(scope="session")216def project_client(temporary_project, api_key, cocalc_host):217"""Create Project client instance using temporary project."""218return Project(project_id=temporary_project['project_id'], api_key=api_key, host=cocalc_host)219220221@pytest.fixture(autouse=True)222def cleanup_kernels_after_test(request, project_client):223"""224Clean up excess Jupyter kernels after test classes that use them.225226Kernel accumulation happens because the kernel pool reuses kernels, but under227heavy test load, old kernels aren't always properly cleaned up by the pool.228This fixture cleans up accumulated kernels BETWEEN test classes (not between229individual tests) to avoid interfering with the pool's reuse strategy.230231The fixture only runs for tests in classes that deal with Jupyter kernels232(TestJupyterExecuteViaHub, TestJupyterExecuteViaProject, TestJupyterKernelManagement)233to avoid interfering with other tests.234"""235yield # Allow test to run236237# Only cleanup for Jupyter-related tests238test_class = request.cls239if test_class is None:240return241242jupyter_test_classes = {243'TestJupyterExecuteViaHub',244'TestJupyterExecuteViaProject',245'TestJupyterKernelManagement',246}247248if test_class.__name__ not in jupyter_test_classes:249return250251# Clean up accumulated kernels carefully252# Only cleanup if we have more kernels than the pool can manage (> 3)253# This gives some buffer to the pool's reuse mechanism254try:255import time256kernels = project_client.system.list_jupyter_kernels()257258# Only cleanup if significantly over pool size (pool size is 2)259# We use threshold of 3 to trigger cleanup260if len(kernels) > 3:261# Keep the 2 most recent kernels (higher PIDs), stop older ones262kernels_sorted = sorted(kernels, key=lambda k: k.get("pid", 0))263kernels_to_stop = kernels_sorted[:-2] # All but the 2 newest264265for kernel in kernels_to_stop:266try:267project_client.system.stop_jupyter_kernel(pid=kernel["pid"])268time.sleep(0.1) # Small delay between kills269except Exception:270# Silently ignore individual kernel failures271pass272except Exception:273# If listing kernels fails, just continue274pass275276277def ensure_python3_kernel(project_client: Project):278"""279Ensure the default python3 Jupyter kernel is installed in the project.280281If not available, install ipykernel and register the kernelspec.282"""283284def try_exec(command: list[str], timeout: int = 60, capture_stdout: bool = False):285try:286result = project_client.system.exec(287command=command[0],288args=command[1:],289timeout=timeout,290)291return (True, result["stdout"] if capture_stdout else None)292except Exception as err:293print(f"Warning: command {command} failed: {err}")294return (False, None)295296def has_python_kernel() -> bool:297ok, stdout = try_exec(298["python3", "-m", "jupyter", "kernelspec", "list", "--json"],299capture_stdout=True,300)301if not ok or stdout is None:302return False303try:304data = json.loads(stdout)305return "python3" in data.get("kernelspecs", {})306except Exception as err:307print(f"Warning: Failed to parse kernelspec list: {err}")308return False309310if has_python_kernel():311return312313print("Installing python3 kernelspec in project...")314# Install pip if needed315try_exec(["python3", "-m", "ensurepip", "--user"], timeout=120)316# Upgrade pip but ignore errors (not fatal)317try_exec(["python3", "-m", "pip", "install", "--user", "--upgrade", "pip"], timeout=120)318319if not try_exec(["python3", "-m", "pip", "install", "--user", "ipykernel"], timeout=300):320raise RuntimeError("Failed to install ipykernel via pip")321322if not try_exec(323[324"python3",325"-m",326"ipykernel",327"install",328"--user",329"--name=python3",330"--display-name=Python 3",331],332timeout=120,333):334raise RuntimeError("Failed to install python3 kernelspec")335336if not has_python_kernel():337raise RuntimeError("Failed to ensure python3 kernelspec is installed in project")338339340# ============================================================================341# Database Cleanup Infrastructure342# ============================================================================343344345@pytest.fixture(scope="session")346def resource_tracker():347"""348Track all resources created during tests for cleanup.349350This fixture provides a dictionary of sets that automatically tracks351all projects, accounts, and organizations created during test execution.352At the end of the test session, all tracked resources are automatically353hard-deleted from the database.354355Usage:356def test_my_feature(hub, resource_tracker):357# Create tracked resources using helper functions358org_id = create_tracked_org(hub, resource_tracker, "test-org")359user_id = create_tracked_user(hub, resource_tracker, "test-org", email="[email protected]")360project_id = create_tracked_project(hub, resource_tracker, title="Test Project")361362# Test logic here...363364# No cleanup needed - happens automatically!365366Returns a dictionary with sets for tracking:367- projects: set of project_id (UUID strings)368- accounts: set of account_id (UUID strings)369- organizations: set of organization names (strings)370"""371tracker = {372'projects': set(),373'accounts': set(),374'organizations': set(),375}376return tracker377378379@pytest.fixture(scope="session")380def check_cleanup_config():381"""382Check cleanup configuration BEFORE any tests run.383Fails fast if cleanup is enabled but database credentials are missing.384"""385cleanup_enabled = os.environ.get("COCALC_TESTS_CLEANUP", "true").lower() != "false"386387if not cleanup_enabled:388print("\n⚠ Database cleanup DISABLED via COCALC_TESTS_CLEANUP=false")389print(" Test resources will remain in the database.")390return # Skip checks if cleanup is disabled391392# Cleanup is enabled - verify required configuration393pghost = os.environ.get("PGHOST")394395# PGHOST is mandatory396if not pghost:397pytest.exit("\n" + "=" * 70 + "\n"398"ERROR: Database cleanup is enabled but PGHOST is not set!\n\n"399"To run tests, you must either:\n"400f" 1. Set PGHOST for socket connection (no password needed):\n"401f" export PGHOST={PGHOST_SOCKET_EXAMPLE}\n\n"402f" 2. Set PGHOST for network connection (requires PGPASSWORD):\n"403f" export PGHOST={PGHOST_NETWORK_EXAMPLE}\n"404" export PGPASSWORD=your_password\n\n"405" 3. Disable cleanup (not recommended):\n"406" export COCALC_TESTS_CLEANUP=false\n"407"=" * 70,408returncode=1)409410411@pytest.fixture(scope="session")412def db_pool(check_cleanup_config):413"""414Create a PostgreSQL connection pool for direct database cleanup.415416Supports both Unix socket and network connections:417418Socket connection (local dev):419export PGUSER=smc420export PGHOST=/path/to/cocalc-data/socket421# No password needed for socket auth422423Network connection:424export PGUSER=smc425export PGHOST=localhost426export PGPORT=5432427export PGPASSWORD=your_password428429To disable cleanup:430export COCALC_TESTS_CLEANUP=false431"""432# Check if cleanup is disabled433cleanup_enabled = os.environ.get("COCALC_TESTS_CLEANUP", "true").lower() != "false"434435if not cleanup_enabled:436print("\n⚠ Database cleanup DISABLED via COCALC_TESTS_CLEANUP=false")437print(" Test resources will remain in the database.")438yield None439return440441# Get connection parameters with defaults442pguser = os.environ.get("PGUSER", "smc")443pghost = os.environ.get("PGHOST")444pgport = os.environ.get("PGPORT", "5432")445pgdatabase = os.environ.get("PGDATABASE", "smc")446pgpassword = os.environ.get("PGPASSWORD")447448# PGHOST is mandatory (already checked in check_cleanup_config, but double-check)449if not pghost:450pytest.fail("\n" + "=" * 70 + "\n"451"ERROR: PGHOST environment variable is required for database cleanup!\n"452"=" * 70)453454# Determine if using socket or network connection455is_socket = pghost.startswith("/")456457# Build connection kwargs458conn_kwargs = {459"host": pghost,460"database": pgdatabase,461"user": pguser,462}463464# Only add port for network connections465if not is_socket:466conn_kwargs["port"] = pgport467468# Only add password if provided469if pgpassword:470conn_kwargs["password"] = pgpassword471472try:473connection_pool = pg_pool.SimpleConnectionPool(1, 5, **conn_kwargs)474475if is_socket:476print(f"\n✓ Database cleanup enabled (socket): {pguser}@{pghost}/{pgdatabase}")477else:478print(f"\n✓ Database cleanup enabled (network): {pguser}@{pghost}:{pgport}/{pgdatabase}")479480yield connection_pool481482connection_pool.closeall()483484except Exception as e:485conn_type = "socket" if is_socket else "network"486pytest.fail("\n" + "=" * 70 + "\n"487f"ERROR: Failed to connect to database ({conn_type}) for cleanup:\n{e}\n\n"488f"Connection details:\n"489f" Host: {pghost}\n"490f" Database: {pgdatabase}\n"491f" User: {pguser}\n" + (f" Port: {pgport}\n" if not is_socket else "") +492"\nTo disable cleanup: export COCALC_TESTS_CLEANUP=false\n"493"=" * 70)494495496def create_tracked_project(hub, resource_tracker, **kwargs):497"""Create a project and register it for cleanup."""498project_id = hub.projects.create_project(**kwargs)499resource_tracker['projects'].add(project_id)500return project_id501502503def create_tracked_user(hub, resource_tracker, org_name, **kwargs):504"""Create a user and register it for cleanup."""505user_id = hub.org.create_user(name=org_name, **kwargs)506resource_tracker['accounts'].add(user_id)507return user_id508509510def create_tracked_org(hub, resource_tracker, org_name):511"""Create an organization and register it for cleanup."""512org_id = hub.org.create(org_name)513resource_tracker['organizations'].add(org_name) # Track by name514return org_id515516517def hard_delete_projects(db_pool, project_ids):518"""Hard delete projects from database using direct SQL."""519if not project_ids:520return521522conn = db_pool.getconn()523try:524cursor = conn.cursor()525for project_id in project_ids:526try:527cursor.execute("DELETE FROM projects WHERE project_id = %s", (project_id, ))528conn.commit()529print(f" ✓ Deleted project {project_id}")530except Exception as e:531conn.rollback()532print(f" ✗ Failed to delete project {project_id}: {e}")533cursor.close()534finally:535db_pool.putconn(conn)536537538def hard_delete_accounts(db_pool, account_ids):539"""540Hard delete accounts from database using direct SQL.541542This also finds and deletes ALL projects where the account is the owner,543including auto-created projects like "My First Project".544"""545if not account_ids:546return547548conn = db_pool.getconn()549try:550cursor = conn.cursor()551for account_id in account_ids:552try:553# First, find ALL projects where this account is the owner554# The users JSONB field has structure: {"account_id": {"group": "owner", ...}}555cursor.execute(556"""557SELECT project_id FROM projects558WHERE users ? %s559AND users->%s->>'group' = 'owner'560""", (account_id, account_id))561owned_projects = cursor.fetchall()562563# Delete all owned projects (including auto-created ones)564for (project_id, ) in owned_projects:565cursor.execute("DELETE FROM projects WHERE project_id = %s", (project_id, ))566print(f" ✓ Deleted owned project {project_id} for account {account_id}")567568# Remove from organizations (admin_account_ids array and users JSONB)569cursor.execute(570"UPDATE organizations SET admin_account_ids = array_remove(admin_account_ids, %s), users = users - %s WHERE users ? %s",571(account_id, account_id, account_id))572573# Remove from remaining project collaborators (users JSONB field)574cursor.execute("UPDATE projects SET users = users - %s WHERE users ? %s", (account_id, account_id))575576# Delete the account577cursor.execute("DELETE FROM accounts WHERE account_id = %s", (account_id, ))578conn.commit()579print(f" ✓ Deleted account {account_id}")580except Exception as e:581conn.rollback()582print(f" ✗ Failed to delete account {account_id}: {e}")583cursor.close()584finally:585db_pool.putconn(conn)586587588def hard_delete_organizations(db_pool, org_names):589"""Hard delete organizations from database using direct SQL."""590if not org_names:591return592593conn = db_pool.getconn()594try:595cursor = conn.cursor()596for org_name in org_names:597try:598cursor.execute("DELETE FROM organizations WHERE name = %s", (org_name, ))599conn.commit()600print(f" ✓ Deleted organization {org_name}")601except Exception as e:602conn.rollback()603print(f" ✗ Failed to delete organization {org_name}: {e}")604cursor.close()605finally:606db_pool.putconn(conn)607608609@pytest.fixture(scope="session", autouse=True)610def cleanup_all_test_resources(hub, resource_tracker, db_pool, request):611"""612Automatically clean up all tracked resources at the end of the test session.613614Cleanup is enabled by default. To disable:615export COCALC_TESTS_CLEANUP=false616"""617618def cleanup():619# Skip cleanup if db_pool is None (cleanup disabled)620if db_pool is None:621print("\n⚠ Skipping database cleanup (COCALC_TESTS_CLEANUP=false)")622return623624print("\n" + "=" * 70)625print("CLEANING UP TEST RESOURCES FROM DATABASE")626print("=" * 70)627628total_projects = len(resource_tracker['projects'])629total_accounts = len(resource_tracker['accounts'])630total_orgs = len(resource_tracker['organizations'])631632print("\nResources to clean up:")633print(f" - Projects: {total_projects}")634print(f" - Accounts: {total_accounts}")635print(f" - Organizations: {total_orgs}")636637# First, soft-delete projects via API (stop them gracefully)638if total_projects > 0:639print(f"\nStopping {total_projects} projects...")640for project_id in resource_tracker['projects']:641try:642cleanup_project(hub, project_id)643except Exception as e:644print(f" Warning: Failed to stop project {project_id}: {e}")645646# Then hard-delete from database in order:647# 1. Projects (no dependencies)648if total_projects > 0:649print(f"\nHard-deleting {total_projects} projects from database...")650hard_delete_projects(db_pool, resource_tracker['projects'])651652# 2. Accounts (must remove from organizations/projects first)653if total_accounts > 0:654print(f"\nHard-deleting {total_accounts} accounts from database...")655hard_delete_accounts(db_pool, resource_tracker['accounts'])656657# 3. Organizations (no dependencies after accounts removed)658if total_orgs > 0:659print(f"\nHard-deleting {total_orgs} organizations from database...")660hard_delete_organizations(db_pool, resource_tracker['organizations'])661662print("\n✓ Test resource cleanup complete!")663print("=" * 70)664665request.addfinalizer(cleanup)666667yield668669670@pytest.fixture(scope="session", autouse=True)671def cleanup_jupyter_kernels_session(project_client):672"""673Clean up all Jupyter kernels created during the test session.674675This session-scoped fixture ensures that all kernels spawned during testing676are properly terminated at the end of the test session. This prevents677orphaned processes from accumulating in the system.678679The fixture runs AFTER all tests complete (via yield), ensuring no680interference with test execution while still guaranteeing cleanup.681"""682yield # Allow all tests to run first683684# After all tests complete, clean up all remaining kernels685try:686kernels = project_client.system.list_jupyter_kernels()687if kernels:688print(f"\n{'='*70}")689print(f"CLEANING UP {len(kernels)} JUPYTER KERNELS FROM TEST SESSION")690print(f"{'='*70}")691for kernel in kernels:692try:693pid = kernel.get("pid")694result = project_client.system.stop_jupyter_kernel(pid=pid)695if result.get("success"):696print(f"✓ Stopped kernel PID {pid}")697else:698print(f"✗ Failed to stop kernel PID {pid}")699except Exception as e:700print(f"✗ Error stopping kernel: {e}")701print(f"{'='*70}\n")702except Exception as e:703print(f"Warning: Failed to clean up jupyter kernels: {e}")704705706