Skip to content

Commit fa5020f

Browse files
fix: optimize badge assignment logic and improve test coverage for sync_user_badges command
1 parent e0835ed commit fa5020f

File tree

2 files changed

+81
-30
lines changed

2 files changed

+81
-30
lines changed

backend/apps/github/management/commands/github_sync_user_badges.py

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,27 +37,22 @@ def sync_owasp_employee_badge(self):
3737
logger.info("Created 'OWASP Employee' badge")
3838
self.stdout.write(f"Created badge: {badge.name}")
3939

40-
# Add badge to OWASP employees
41-
employees = User.objects.filter(is_owasp_employee=True)
42-
count = 0
40+
# Assign badge to employees who don't have it (avoiding N+1 queries)
41+
employees_without_badge = User.objects.filter(is_owasp_employee=True).exclude(badges=badge)
42+
count = employees_without_badge.count()
4343

44-
for user in employees:
45-
# Check if the user already has the badge
46-
if not user.badges.filter(id=badge.id).exists():
47-
user.badges.add(badge)
48-
count += 1
44+
for user in employees_without_badge:
45+
user.badges.add(badge)
4946

50-
if count:
51-
logger.info("Added 'OWASP Employee' badge to %s users", count)
52-
self.stdout.write(f"Added badge to {count} employees")
47+
logger.info("Added 'OWASP Employee' badge to %s users", count)
48+
self.stdout.write(f"Added badge to {count} employees")
5349

5450
# Remove badge from non-OWASP employees
55-
non_employees = User.objects.filter(is_owasp_employee=False).filter(badges=badge)
51+
non_employees = User.objects.filter(is_owasp_employee=False, badges=badge)
5652
removed_count = non_employees.count()
5753

5854
for user in non_employees:
5955
user.badges.remove(badge)
6056

61-
if removed_count:
62-
logger.info("Removed 'OWASP Employee' badge from %s users", removed_count)
63-
self.stdout.write(f"Removed badge from {removed_count} non-employees")
57+
logger.info("Removed 'OWASP Employee' badge from %s users", removed_count)
58+
self.stdout.write(f"Removed badge from {removed_count} non-employees")

backend/tests/apps/github/management/commands/github_sync_user_badges_test.py

Lines changed: 71 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
import pytest
77
from django.core.management import call_command
88

9+
OWASP_EMPLOYEE_BADGE_NAME = "OWASP Employee"
10+
911

1012
@pytest.fixture
1113
def mock_badge():
1214
badge = MagicMock()
13-
badge.name = "OWASP Employee"
15+
badge.name = OWASP_EMPLOYEE_BADGE_NAME
1416
badge.css_class = "fa-user-shield"
1517
badge.id = 1
1618
return badge
@@ -24,32 +26,28 @@ class TestSyncUserBadgesCommand:
2426
def test_sync_owasp_employee_badge(self, mock_user_filter, mock_badge_get_or_create):
2527
# Set up badge mock
2628
mock_badge = MagicMock()
27-
mock_badge.name = "OWASP Employee"
29+
mock_badge.name = OWASP_EMPLOYEE_BADGE_NAME
2830
mock_badge.id = 1
2931
mock_badge_get_or_create.return_value = (mock_badge, False)
3032

31-
# Set up employee mocks
33+
# Set up employee mocks - with exclude() method properly mocked
3234
mock_employee = MagicMock()
33-
mock_employee.badges.filter.return_value.exists.return_value = False
3435
mock_employees = MagicMock()
35-
mock_employees.__iter__.return_value = [mock_employee]
36-
mock_employees.count.return_value = 1
36+
mock_employees_without_badge = MagicMock()
37+
mock_employees_without_badge.__iter__.return_value = [mock_employee]
38+
mock_employees_without_badge.count.return_value = 1
39+
mock_employees.exclude.return_value = mock_employees_without_badge
3740

3841
# Set up former employee mocks
3942
mock_former_employee = MagicMock()
4043
mock_former_employees = MagicMock()
4144
mock_former_employees.__iter__.return_value = [mock_former_employee]
4245
mock_former_employees.count.return_value = 1
4346

44-
# Set up the double filter behavior
45-
mock_non_employees_first_filter = MagicMock()
46-
mock_non_employees_first_filter.filter.return_value = mock_former_employees
47-
4847
# Configure filter side effects
4948
mock_user_filter.side_effect = [
5049
mock_employees, # is_owasp_employee=True
51-
# is_owasp_employee=False, will need .filter(badges=badge)
52-
mock_non_employees_first_filter,
50+
mock_former_employees, # is_owasp_employee=False, badges=badge
5351
]
5452

5553
# Call the command
@@ -71,7 +69,7 @@ def test_sync_owasp_employee_badge(self, mock_user_filter, mock_badge_get_or_cre
7169
def test_badge_creation(self, mock_user_filter, mock_badge_get_or_create):
7270
# Set up badge creation mock
7371
mock_badge = MagicMock()
74-
mock_badge.name = "OWASP Employee"
72+
mock_badge.name = OWASP_EMPLOYEE_BADGE_NAME
7573
mock_badge_get_or_create.return_value = (mock_badge, True)
7674

7775
# Set up empty querysets
@@ -88,9 +86,67 @@ def test_badge_creation(self, mock_user_filter, mock_badge_get_or_create):
8886
out = StringIO()
8987
call_command("github_sync_user_badges", stdout=out)
9088

91-
# Verify badge creation
92-
mock_badge_get_or_create.assert_called_once()
89+
# Verify badge creation and defaults
90+
91+
mock_badge_get_or_create.assert_called_once_with(
92+
name="OWASP Employee",
93+
defaults={
94+
"description": "Official OWASP Employee",
95+
"css_class": "fa-user-shield",
96+
"weight": 100,
97+
},
98+
)
9399

94100
# Check command output
95101
output = out.getvalue()
96102
assert f"Created badge: {mock_badge.name}" in output
103+
104+
@patch("apps.owasp.models.badge.Badge.objects.get_or_create")
105+
@patch("apps.github.models.user.User.objects.filter")
106+
def test_command_idempotency(self, mock_user_filter, mock_badge_get_or_create):
107+
"""Test that running the command multiple times has the same effect as running it once."""
108+
# Set up badge mock
109+
mock_badge = MagicMock()
110+
mock_badge.name = OWASP_EMPLOYEE_BADGE_NAME
111+
mock_badge.id = 1
112+
mock_badge_get_or_create.return_value = (mock_badge, False)
113+
114+
# Set up employee mock that already has the badge
115+
mock_employee_with_badge = MagicMock()
116+
# This employee already has the badge
117+
mock_employee_with_badge.badges.filter.return_value.exists.return_value = True
118+
mock_employees = MagicMock()
119+
mock_employees.__iter__.return_value = [mock_employee_with_badge]
120+
# Using exclude() would return 0 employees without the badge
121+
mock_employees.exclude.return_value = MagicMock()
122+
mock_employees.exclude.return_value.count.return_value = 0
123+
124+
# No former employees have the badge
125+
mock_non_employees_filter = MagicMock()
126+
mock_non_employees_filter.count.return_value = 0
127+
mock_non_employees_filter.__iter__.return_value = []
128+
129+
# Configure filter side effects for two command runs
130+
mock_user_filter.side_effect = [
131+
mock_employees, # is_owasp_employee=True (first run)
132+
mock_non_employees_filter, # is_owasp_employee=False (first run)
133+
mock_employees, # is_owasp_employee=True (second run)
134+
mock_non_employees_filter, # is_owasp_employee=False (second run)
135+
]
136+
137+
# First run
138+
out1 = StringIO()
139+
call_command("github_sync_user_badges", stdout=out1)
140+
141+
# Second run
142+
out2 = StringIO()
143+
call_command("github_sync_user_badges", stdout=out2)
144+
145+
# Verify no add/remove operations were performed
146+
mock_employee_with_badge.badges.add.assert_not_called()
147+
148+
# Check both outputs contain zero-count messages
149+
assert "Added badge to 0 employees" in out1.getvalue()
150+
assert "Removed badge from 0 non-employees" in out1.getvalue()
151+
assert "Added badge to 0 employees" in out2.getvalue()
152+
assert "Removed badge from 0 non-employees" in out2.getvalue()

0 commit comments

Comments
 (0)