import threading from concurrent.futures import ThreadPoolExecutor from unittest import skipUnless from unittest.mock import patch from django.db import close_old_connections, connection, transaction from django.test import TransactionTestCase, override_settings from members.billing import sync_account from members.models import Account, ManagedGroup @skipUnless(connection.vendor == "postgresql", "Requires real PostgreSQL row locks") class PostgreSQLLockTests(TransactionTestCase): def setUp(self): self.account = Account.objects.create(uid="lock-test", email="lock@example.org", ldap_dn="uid=lock-test,dc=example,dc=org", ldap_uuid="lock-test-uuid") @override_settings(MEMBERSHIP_TIERS={}) def test_checkout_account_lock_excludes_reconciliation(self): attempted = threading.Event() acquired = threading.Event() def worker(): close_old_connections() attempted.set() try: sync_account(self.account.pk) finally: close_old_connections() def refresh(member): acquired.set() with patch("members.billing.refresh_billing", side_effect=refresh): with ThreadPoolExecutor(max_workers=1) as pool: with transaction.atomic(): # Checkout uses this identical row lock before Stripe reads. Account.objects.select_for_update().get(pk=self.account.pk) future = pool.submit(worker) self.assertTrue(attempted.wait(5)) with connection.cursor() as cursor: cursor.execute("SELECT pg_backend_pid()") # The worker cannot reach billing while Checkout owns lock. self.assertFalse(acquired.is_set()) future.result(timeout=10) self.assertTrue(acquired.is_set()) @override_settings(MEMBERSHIP_TIERS={"test": {"group_dn": "cn=paid,dc=example,dc=org"}}) def test_intent_commits_before_external_grant_failure(self): with patch("members.billing.reconcile_groups", side_effect=OSError("directory unavailable")): with self.assertRaises(OSError): sync_account(self.account.pk) # A later failed external side effect rolls back its own transaction, # not the previously committed record of the managed group. self.assertEqual(ManagedGroup.objects.get(account=self.account).dn, "cn=paid,dc=example,dc=org")