Skip to content

Commit 52b2394

Browse files
committed
cocalc-api: introduce a cleanup logic, of tracked accounts, orgs and projects. (managing an ephemeral database via the API is a bit tricky)
1 parent 7390989 commit 52b2394

File tree

4 files changed

+930
-301
lines changed

4 files changed

+930
-301
lines changed

src/python/cocalc-api/pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,11 @@ dev = [
7676
"mkdocs-material",
7777
"mkdocstrings[python]",
7878
"mypy",
79+
"psycopg2-binary",
7980
"pyright",
80-
"pytest>=8.4.1",
81+
"pytest>=8.4.2",
8182
"pytest-cov",
82-
"ruff>=0.12.11",
83+
"ruff>=0.13.2",
84+
"types-psycopg2",
8385
"yapf",
8486
]

src/python/cocalc-api/tests/conftest.py

Lines changed: 340 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88

99
from cocalc_api import Hub, Project
1010

11+
from psycopg2 import pool as pg_pool
12+
13+
# Database configuration examples (DRY principle)
14+
PGHOST_SOCKET_EXAMPLE = "/path/to/cocalc-data/socket"
15+
PGHOST_NETWORK_EXAMPLE = "localhost"
16+
1117

1218
def assert_valid_uuid(value, description="value"):
1319
"""
@@ -67,19 +73,18 @@ def hub(api_key, cocalc_host):
6773

6874

6975
@pytest.fixture(scope="session")
70-
def temporary_project(hub, request):
76+
def temporary_project(hub, resource_tracker, request):
7177
"""
7278
Create a temporary project for testing and return project info.
7379
Uses a session-scoped fixture so only ONE project is created for the entire test suite.
7480
"""
75-
import time
76-
7781
# Create a project with a timestamp to make it unique and identifiable
7882
timestamp = time.strftime("%Y%m%d-%H%M%S")
7983
title = f"CoCalc API Test {timestamp}"
8084
description = "Temporary project created by cocalc-api tests"
8185

82-
project_id = hub.projects.create_project(title=title, description=description)
86+
# Use tracked creation
87+
project_id = create_tracked_project(hub, resource_tracker, title=title, description=description)
8388

8489
# Start the project so it can respond to API calls
8590
try:
@@ -104,11 +109,7 @@ def temporary_project(hub, request):
104109

105110
project_info = {'project_id': project_id, 'title': title, 'description': description}
106111

107-
# Register cleanup using finalizer
108-
def cleanup():
109-
cleanup_project(hub, project_id)
110-
111-
request.addfinalizer(cleanup)
112+
# Note: No finalizer needed - cleanup happens automatically via cleanup_all_test_resources
112113

113114
return project_info
114115

@@ -117,3 +118,333 @@ def cleanup():
117118
def project_client(temporary_project, api_key, cocalc_host):
118119
"""Create Project client instance using temporary project."""
119120
return Project(project_id=temporary_project['project_id'], api_key=api_key, host=cocalc_host)
121+
122+
123+
# ============================================================================
124+
# Database Cleanup Infrastructure
125+
# ============================================================================
126+
127+
128+
@pytest.fixture(scope="session")
129+
def resource_tracker():
130+
"""
131+
Track all resources created during tests for cleanup.
132+
133+
This fixture provides a dictionary of sets that automatically tracks
134+
all projects, accounts, and organizations created during test execution.
135+
At the end of the test session, all tracked resources are automatically
136+
hard-deleted from the database.
137+
138+
Usage:
139+
def test_my_feature(hub, resource_tracker):
140+
# Create tracked resources using helper functions
141+
org_id = create_tracked_org(hub, resource_tracker, "test-org")
142+
user_id = create_tracked_user(hub, resource_tracker, "test-org", email="[email protected]")
143+
project_id = create_tracked_project(hub, resource_tracker, title="Test Project")
144+
145+
# Test logic here...
146+
147+
# No cleanup needed - happens automatically!
148+
149+
Returns a dictionary with sets for tracking:
150+
- projects: set of project_id (UUID strings)
151+
- accounts: set of account_id (UUID strings)
152+
- organizations: set of organization names (strings)
153+
"""
154+
tracker = {
155+
'projects': set(),
156+
'accounts': set(),
157+
'organizations': set(),
158+
}
159+
return tracker
160+
161+
162+
@pytest.fixture(scope="session")
163+
def check_cleanup_config():
164+
"""
165+
Check cleanup configuration BEFORE any tests run.
166+
Fails fast if cleanup is enabled but database credentials are missing.
167+
"""
168+
cleanup_enabled = os.environ.get("COCALC_TESTS_CLEANUP", "true").lower() != "false"
169+
170+
if not cleanup_enabled:
171+
print("\n⚠ Database cleanup DISABLED via COCALC_TESTS_CLEANUP=false")
172+
print(" Test resources will remain in the database.")
173+
return # Skip checks if cleanup is disabled
174+
175+
# Cleanup is enabled - verify required configuration
176+
pghost = os.environ.get("PGHOST")
177+
pgpassword = os.environ.get("PGPASSWORD")
178+
179+
# PGHOST is mandatory
180+
if not pghost:
181+
pytest.exit("\n" + "=" * 70 + "\n"
182+
"ERROR: Database cleanup is enabled but PGHOST is not set!\n\n"
183+
"To run tests, you must either:\n"
184+
f" 1. Set PGHOST for socket connection (no password needed):\n"
185+
f" export PGHOST={PGHOST_SOCKET_EXAMPLE}\n\n"
186+
f" 2. Set PGHOST for network connection (requires PGPASSWORD):\n"
187+
f" export PGHOST={PGHOST_NETWORK_EXAMPLE}\n"
188+
" export PGPASSWORD=your_password\n\n"
189+
" 3. Disable cleanup (not recommended):\n"
190+
" export COCALC_TESTS_CLEANUP=false\n"
191+
"=" * 70,
192+
returncode=1)
193+
194+
195+
@pytest.fixture(scope="session")
196+
def db_pool(check_cleanup_config):
197+
"""
198+
Create a PostgreSQL connection pool for direct database cleanup.
199+
200+
Supports both Unix socket and network connections:
201+
202+
Socket connection (local dev):
203+
export PGUSER=smc
204+
export PGHOST=/path/to/cocalc-data/socket
205+
# No password needed for socket auth
206+
207+
Network connection:
208+
export PGUSER=smc
209+
export PGHOST=localhost
210+
export PGPORT=5432
211+
export PGPASSWORD=your_password
212+
213+
To disable cleanup:
214+
export COCALC_TESTS_CLEANUP=false
215+
"""
216+
# Check if cleanup is disabled
217+
cleanup_enabled = os.environ.get("COCALC_TESTS_CLEANUP", "true").lower() != "false"
218+
219+
if not cleanup_enabled:
220+
print("\n⚠ Database cleanup DISABLED via COCALC_TESTS_CLEANUP=false")
221+
print(" Test resources will remain in the database.")
222+
return None
223+
224+
# Get connection parameters with defaults
225+
pguser = os.environ.get("PGUSER", "smc")
226+
pghost = os.environ.get("PGHOST")
227+
pgport = os.environ.get("PGPORT", "5432")
228+
pgdatabase = os.environ.get("PGDATABASE", "smc")
229+
pgpassword = os.environ.get("PGPASSWORD")
230+
231+
# PGHOST is mandatory (already checked in check_cleanup_config, but double-check)
232+
if not pghost:
233+
pytest.fail("\n" + "=" * 70 + "\n"
234+
"ERROR: PGHOST environment variable is required for database cleanup!\n"
235+
"=" * 70)
236+
237+
# Determine if using socket or network connection
238+
is_socket = pghost.startswith("/")
239+
240+
# Build connection kwargs
241+
conn_kwargs = {
242+
"host": pghost,
243+
"database": pgdatabase,
244+
"user": pguser,
245+
}
246+
247+
# Only add port for network connections
248+
if not is_socket:
249+
conn_kwargs["port"] = pgport
250+
251+
# Only add password if provided
252+
if pgpassword:
253+
conn_kwargs["password"] = pgpassword
254+
255+
try:
256+
connection_pool = pg_pool.SimpleConnectionPool(1, 5, **conn_kwargs)
257+
258+
if is_socket:
259+
print(f"\n✓ Database cleanup enabled (socket): {pguser}@{pghost}/{pgdatabase}")
260+
else:
261+
print(f"\n✓ Database cleanup enabled (network): {pguser}@{pghost}:{pgport}/{pgdatabase}")
262+
263+
yield connection_pool
264+
265+
connection_pool.closeall()
266+
267+
except Exception as e:
268+
conn_type = "socket" if is_socket else "network"
269+
pytest.fail("\n" + "=" * 70 + "\n"
270+
f"ERROR: Failed to connect to database ({conn_type}) for cleanup:\n{e}\n\n"
271+
f"Connection details:\n"
272+
f" Host: {pghost}\n"
273+
f" Database: {pgdatabase}\n"
274+
f" User: {pguser}\n" + (f" Port: {pgport}\n" if not is_socket else "") +
275+
"\nTo disable cleanup: export COCALC_TESTS_CLEANUP=false\n"
276+
"=" * 70)
277+
278+
279+
def create_tracked_project(hub, resource_tracker, **kwargs):
280+
"""Create a project and register it for cleanup."""
281+
project_id = hub.projects.create_project(**kwargs)
282+
resource_tracker['projects'].add(project_id)
283+
return project_id
284+
285+
286+
def create_tracked_user(hub, resource_tracker, org_name, **kwargs):
287+
"""Create a user and register it for cleanup."""
288+
user_id = hub.org.create_user(name=org_name, **kwargs)
289+
resource_tracker['accounts'].add(user_id)
290+
return user_id
291+
292+
293+
def create_tracked_org(hub, resource_tracker, org_name):
294+
"""Create an organization and register it for cleanup."""
295+
org_id = hub.org.create(org_name)
296+
resource_tracker['organizations'].add(org_name) # Track by name
297+
return org_id
298+
299+
300+
def hard_delete_projects(db_pool, project_ids):
301+
"""Hard delete projects from database using direct SQL."""
302+
if not project_ids:
303+
return
304+
305+
conn = db_pool.getconn()
306+
try:
307+
cursor = conn.cursor()
308+
for project_id in project_ids:
309+
try:
310+
cursor.execute("DELETE FROM projects WHERE project_id = %s", (project_id, ))
311+
conn.commit()
312+
print(f" ✓ Deleted project {project_id}")
313+
except Exception as e:
314+
conn.rollback()
315+
print(f" ✗ Failed to delete project {project_id}: {e}")
316+
cursor.close()
317+
finally:
318+
db_pool.putconn(conn)
319+
320+
321+
def hard_delete_accounts(db_pool, account_ids):
322+
"""
323+
Hard delete accounts from database using direct SQL.
324+
325+
This also finds and deletes ALL projects where the account is the owner,
326+
including auto-created projects like "My First Project".
327+
"""
328+
if not account_ids:
329+
return
330+
331+
conn = db_pool.getconn()
332+
try:
333+
cursor = conn.cursor()
334+
for account_id in account_ids:
335+
try:
336+
# First, find ALL projects where this account is the owner
337+
# The users JSONB field has structure: {"account_id": {"group": "owner", ...}}
338+
cursor.execute(
339+
"""
340+
SELECT project_id FROM projects
341+
WHERE users ? %s
342+
AND users->%s->>'group' = 'owner'
343+
""", (account_id, account_id))
344+
owned_projects = cursor.fetchall()
345+
346+
# Delete all owned projects (including auto-created ones)
347+
for (project_id, ) in owned_projects:
348+
cursor.execute("DELETE FROM projects WHERE project_id = %s", (project_id, ))
349+
print(f" ✓ Deleted owned project {project_id} for account {account_id}")
350+
351+
# Remove from organizations (admin_account_ids array and users JSONB)
352+
cursor.execute(
353+
"UPDATE organizations SET admin_account_ids = array_remove(admin_account_ids, %s), users = users - %s WHERE users ? %s",
354+
(account_id, account_id, account_id))
355+
356+
# Remove from remaining project collaborators (users JSONB field)
357+
cursor.execute("UPDATE projects SET users = users - %s WHERE users ? %s", (account_id, account_id))
358+
359+
# Delete the account
360+
cursor.execute("DELETE FROM accounts WHERE account_id = %s", (account_id, ))
361+
conn.commit()
362+
print(f" ✓ Deleted account {account_id}")
363+
except Exception as e:
364+
conn.rollback()
365+
print(f" ✗ Failed to delete account {account_id}: {e}")
366+
cursor.close()
367+
finally:
368+
db_pool.putconn(conn)
369+
370+
371+
def hard_delete_organizations(db_pool, org_names):
372+
"""Hard delete organizations from database using direct SQL."""
373+
if not org_names:
374+
return
375+
376+
conn = db_pool.getconn()
377+
try:
378+
cursor = conn.cursor()
379+
for org_name in org_names:
380+
try:
381+
cursor.execute("DELETE FROM organizations WHERE name = %s", (org_name, ))
382+
conn.commit()
383+
print(f" ✓ Deleted organization {org_name}")
384+
except Exception as e:
385+
conn.rollback()
386+
print(f" ✗ Failed to delete organization {org_name}: {e}")
387+
cursor.close()
388+
finally:
389+
db_pool.putconn(conn)
390+
391+
392+
@pytest.fixture(scope="session", autouse=True)
393+
def cleanup_all_test_resources(hub, resource_tracker, db_pool, request):
394+
"""
395+
Automatically clean up all tracked resources at the end of the test session.
396+
397+
Cleanup is enabled by default. To disable:
398+
export COCALC_TESTS_CLEANUP=false
399+
"""
400+
401+
def cleanup():
402+
# Skip cleanup if db_pool is None (cleanup disabled)
403+
if db_pool is None:
404+
print("\n⚠ Skipping database cleanup (COCALC_TESTS_CLEANUP=false)")
405+
return
406+
407+
print("\n" + "=" * 70)
408+
print("CLEANING UP TEST RESOURCES FROM DATABASE")
409+
print("=" * 70)
410+
411+
total_projects = len(resource_tracker['projects'])
412+
total_accounts = len(resource_tracker['accounts'])
413+
total_orgs = len(resource_tracker['organizations'])
414+
415+
print("\nResources to clean up:")
416+
print(f" - Projects: {total_projects}")
417+
print(f" - Accounts: {total_accounts}")
418+
print(f" - Organizations: {total_orgs}")
419+
420+
# First, soft-delete projects via API (stop them gracefully)
421+
if total_projects > 0:
422+
print(f"\nStopping {total_projects} projects...")
423+
for project_id in resource_tracker['projects']:
424+
try:
425+
cleanup_project(hub, project_id)
426+
except Exception as e:
427+
print(f" Warning: Failed to stop project {project_id}: {e}")
428+
429+
# Then hard-delete from database in order:
430+
# 1. Projects (no dependencies)
431+
if total_projects > 0:
432+
print(f"\nHard-deleting {total_projects} projects from database...")
433+
hard_delete_projects(db_pool, resource_tracker['projects'])
434+
435+
# 2. Accounts (must remove from organizations/projects first)
436+
if total_accounts > 0:
437+
print(f"\nHard-deleting {total_accounts} accounts from database...")
438+
hard_delete_accounts(db_pool, resource_tracker['accounts'])
439+
440+
# 3. Organizations (no dependencies after accounts removed)
441+
if total_orgs > 0:
442+
print(f"\nHard-deleting {total_orgs} organizations from database...")
443+
hard_delete_organizations(db_pool, resource_tracker['organizations'])
444+
445+
print("\n✓ Test resource cleanup complete!")
446+
print("=" * 70)
447+
448+
request.addfinalizer(cleanup)
449+
450+
yield

0 commit comments

Comments
 (0)