8
8
9
9
from cocalc_api import Hub , Project
10
10
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
+
11
17
12
18
def assert_valid_uuid (value , description = "value" ):
13
19
"""
@@ -67,19 +73,18 @@ def hub(api_key, cocalc_host):
67
73
68
74
69
75
@pytest .fixture (scope = "session" )
70
- def temporary_project (hub , request ):
76
+ def temporary_project (hub , resource_tracker , request ):
71
77
"""
72
78
Create a temporary project for testing and return project info.
73
79
Uses a session-scoped fixture so only ONE project is created for the entire test suite.
74
80
"""
75
- import time
76
-
77
81
# Create a project with a timestamp to make it unique and identifiable
78
82
timestamp = time .strftime ("%Y%m%d-%H%M%S" )
79
83
title = f"CoCalc API Test { timestamp } "
80
84
description = "Temporary project created by cocalc-api tests"
81
85
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 )
83
88
84
89
# Start the project so it can respond to API calls
85
90
try :
@@ -104,11 +109,7 @@ def temporary_project(hub, request):
104
109
105
110
project_info = {'project_id' : project_id , 'title' : title , 'description' : description }
106
111
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
112
113
113
114
return project_info
114
115
@@ -117,3 +118,333 @@ def cleanup():
117
118
def project_client (temporary_project , api_key , cocalc_host ):
118
119
"""Create Project client instance using temporary project."""
119
120
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