88
99from 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
1218def 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():
117118def 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+ "\n To 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 ("\n Resources 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"\n Stopping { 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"\n Hard-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"\n Hard-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"\n Hard-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