Skip to content

Commit 829846e

Browse files
authored
AAP-51352 Allow running tests with --nomigrations (ansible#853)
I was able to test locally with ``` pytest test_app/tests/ --nomigrations ``` I got 5 failures, 2 were due to local dependency config issues, and 3 were some specifically from the resource_registry app related to this, but were directly touching `ServiceID` in some way so those just aren't going to work with this and that's okay.
1 parent 804e5a8 commit 829846e

File tree

3 files changed

+161
-5
lines changed

3 files changed

+161
-5
lines changed

ansible_base/resource_registry/models/service_identifier.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import sys
12
import uuid
23

3-
from django.db import models
4+
from django.conf import settings
5+
from django.db import IntegrityError, models, transaction
46

57

68
class ServiceID(models.Model):
@@ -23,5 +25,21 @@ def save(self, *args, **kwargs):
2325
def service_id():
2426
global _service_id
2527
if not _service_id:
26-
_service_id = str(ServiceID.objects.first().pk)
28+
obj = ServiceID.objects.first()
29+
if obj is None:
30+
if settings.DEBUG or "pytest" in sys.argv:
31+
try:
32+
with transaction.atomic():
33+
obj = ServiceID.objects.create()
34+
# Check if another process also created one during the race
35+
if ServiceID.objects.count() > 1:
36+
# We lost the race, delete ours and use the other
37+
obj.delete()
38+
obj = ServiceID.objects.first()
39+
except IntegrityError:
40+
# Another thread/process won the race—read it
41+
obj = ServiceID.objects.first()
42+
else:
43+
raise RuntimeError('Expected ServiceID to be created in data migrations but was not found')
44+
_service_id = str(obj.pk)
2745
return _service_id

test_app/tests/conftest.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from django.contrib.contenttypes.models import ContentType
1414
from django.db.migrations.recorder import MigrationRecorder
1515
from django.db.models.signals import post_migrate
16+
from django.db.utils import ProgrammingError
1617
from django.test.client import RequestFactory
1718
from django.test.utils import override_settings
1819
from drf_spectacular.generators import SchemaGenerator
@@ -47,7 +48,13 @@ def test_migrations_okay(apps=None, app_config=None, **kwargs):
4748
return # so that it is only ran once
4849
disk_steps = defaultdict(set)
4950
app_exceptions = {'default': 'auth', 'social_auth': 'social_django'}
50-
for app in MigrationRecorder.Migration.objects.values_list('app', flat=True).distinct():
51+
52+
try:
53+
app_list = list(MigrationRecorder.Migration.objects.values_list('app', flat=True).distinct())
54+
except ProgrammingError:
55+
return
56+
57+
for app in app_list:
5158
if app in app_exceptions:
5259
continue
5360
try:
Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,148 @@
1+
from unittest.mock import patch
2+
13
import pytest
4+
from django.db import IntegrityError
25

3-
from ansible_base.resource_registry.models.service_identifier import ServiceID
6+
from ansible_base.resource_registry.models.service_identifier import ServiceID, service_id
47

58

69
@pytest.mark.django_db
710
def test_service_id_already_exists():
811
"The resource registry already creates this, so we expect an error here"
12+
# Ensure one exists first
13+
if not ServiceID.objects.exists():
14+
ServiceID.objects.create()
915
with pytest.raises(RuntimeError) as exc:
1016
ServiceID.objects.create()
1117
assert 'This service already has a ServiceID' in str(exc)
1218

1319

1420
@pytest.mark.django_db
1521
def test_service_id_does_not_yet_exist():
16-
ServiceID.objects.first().delete() # clear out what migration created
22+
ServiceID.objects.all().delete() # clear out any existing
1723
ServiceID.objects.create() # expect no error
24+
25+
26+
@pytest.mark.django_db
27+
def test_service_id_function_auto_creates_in_debug():
28+
"""
29+
Generated by Claude Sonnet 4.5
30+
Test that service_id() auto-creates ServiceID when none exists in DEBUG/pytest mode.
31+
"""
32+
# Clear the global cache and delete existing ServiceID
33+
import sys
34+
35+
import ansible_base.resource_registry.models.service_identifier as sid_module
36+
37+
sid_module._service_id = None
38+
ServiceID.objects.all().delete()
39+
40+
# Ensure pytest is in sys.argv to trigger auto-creation
41+
with patch.object(sys, 'argv', ['pytest']):
42+
result = service_id()
43+
44+
assert result is not None
45+
assert ServiceID.objects.count() == 1
46+
# The service was created successfully
47+
48+
49+
@pytest.mark.django_db
50+
def test_service_id_function_handles_integrity_error():
51+
"""
52+
Generated by Claude Sonnet 4.5
53+
Test that service_id() handles IntegrityError when another thread/process creates ServiceID first.
54+
"""
55+
# Clear the global cache and delete existing ServiceID
56+
import sys
57+
58+
import ansible_base.resource_registry.models.service_identifier as sid_module
59+
60+
sid_module._service_id = None
61+
ServiceID.objects.all().delete()
62+
63+
# Pre-create an object to simulate another process winning the race
64+
# We'll create it before the patched create is called
65+
existing_obj = ServiceID.objects.create()
66+
67+
# Delete from cache and reset so service_id() will try to create
68+
sid_module._service_id = None
69+
# Don't delete from DB - simulate another process created it
70+
71+
# Mock create to raise IntegrityError
72+
def mock_create(*args, **kwargs):
73+
# Raise IntegrityError to simulate race condition
74+
raise IntegrityError("Duplicate key")
75+
76+
# Ensure pytest is in sys.argv to trigger auto-creation path
77+
with patch.object(sys, 'argv', ['pytest']):
78+
# Mock objects.first() to return None first time, then the actual object
79+
with patch.object(ServiceID.objects, 'first', side_effect=[None, existing_obj]):
80+
with patch.object(ServiceID.objects, 'create', side_effect=mock_create):
81+
result = service_id()
82+
83+
assert result is not None
84+
# The IntegrityError was handled and it fetched the existing record via first()
85+
assert ServiceID.objects.count() == 1
86+
87+
88+
@pytest.mark.django_db
89+
def test_service_id_function_cleans_up_duplicate_on_race():
90+
"""
91+
Generated by Claude Sonnet 4.5
92+
Test that service_id() detects when two objects were created in a race and cleans up the duplicate.
93+
"""
94+
import sys
95+
96+
import ansible_base.resource_registry.models.service_identifier as sid_module
97+
98+
sid_module._service_id = None
99+
ServiceID.objects.all().delete()
100+
101+
# Create the "winner" object that another process created
102+
winner_obj = ServiceID.objects.create()
103+
winner_pk = winner_obj.pk
104+
105+
sid_module._service_id = None
106+
107+
# Track what we create and delete
108+
created_obj = None
109+
created_pk = None
110+
deleted_pks = []
111+
112+
# Mock create to bypass save() exists check and track the created object
113+
def mock_create(*args, **kwargs):
114+
nonlocal created_obj, created_pk
115+
import uuid
116+
117+
from django.db import models
118+
119+
created_obj = ServiceID(id=uuid.uuid4())
120+
# Bypass the exists check in save()
121+
models.Model.save(created_obj)
122+
created_pk = created_obj.pk # Capture pk before it gets deleted
123+
return created_obj
124+
125+
# Track deletions
126+
original_delete = ServiceID.delete
127+
128+
def track_delete(self, *args, **kwargs):
129+
deleted_pks.append(self.pk)
130+
return original_delete(self, *args, **kwargs)
131+
132+
with patch.object(sys, 'argv', ['pytest']):
133+
# first() returns None to trigger creation path, then winner_obj for cleanup
134+
with patch.object(ServiceID.objects, 'first', side_effect=[None, winner_obj]):
135+
with patch.object(ServiceID.objects, 'create', side_effect=mock_create):
136+
# count() returns 2 to trigger cleanup, then actual count
137+
with patch.object(ServiceID.objects, 'count', side_effect=[2, 1]):
138+
with patch.object(ServiceID, 'delete', track_delete):
139+
result = service_id()
140+
141+
# Verify our created object was deleted
142+
assert created_obj is not None
143+
assert created_pk in deleted_pks
144+
# Verify we returned the winner's ID
145+
assert result == str(winner_pk)
146+
# Verify only one object remains in the actual database
147+
assert ServiceID.objects.count() == 1
148+
assert ServiceID.objects.filter(pk=winner_pk).exists()

0 commit comments

Comments
 (0)