-
-
Notifications
You must be signed in to change notification settings - Fork 662
Add backend tests for certificate generation #5342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
anurag2787
wants to merge
12
commits into
OWASP:feature/contributor-recognition-program
Choose a base branch
from
anurag2787:score-calculation-test
base: feature/contributor-recognition-program
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
fc376d5
Added test for backend
anurag2787 ede615d
adress review
anurag2787 e2657cf
Merge branch 'feature/contributor-recognition-program' into score-cal…
anurag2787 535186a
run ci test
anurag2787 a67a449
Merge branch 'score-calculation-test' of github.com:anurag2787/Nest i…
anurag2787 9df302d
revert
anurag2787 5693a71
Merge branch 'feature/contributor-recognition-program' into score-cal…
anurag2787 89a7586
fixed test
anurag2787 fa17a2b
fixed coderabbit review
anurag2787 f139892
Updated test
anurag2787 a3be19e
Merge branch 'feature/contributor-recognition-program' into score-cal…
anurag2787 8900d88
Updated certificate graphql test
anurag2787 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
65 changes: 65 additions & 0 deletions
65
backend/tests/unit/apps/owasp/api/internal/nodes/certificate_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| """Tests for Certificate GraphQL node.""" | ||
|
|
||
| from unittest.mock import Mock | ||
|
|
||
| from apps.owasp.api.internal.nodes.certificate import CertificateNode | ||
| from tests.unit.apps.common.graphql_node_base_test import GraphQLNodeBaseTest | ||
|
|
||
|
|
||
| class TestCertificateNode(GraphQLNodeBaseTest): | ||
| """Test cases for CertificateNode class.""" | ||
|
|
||
| def test_node_fields(self): | ||
| """Test node has expected fields.""" | ||
| field_names = {field.name for field in CertificateNode.__strawberry_definition__.fields} | ||
| expected_field_names = { | ||
| "github_user", | ||
| "id", | ||
| "is_verified", | ||
| "issued_at", | ||
| "score", | ||
| "tier", | ||
| } | ||
| assert field_names == expected_field_names | ||
|
|
||
| def test_tier_resolver(self): | ||
| """Test tier resolver returns human-readable display name.""" | ||
| mock_cert = Mock() | ||
| mock_cert.get_tier_display.return_value = "Level 1" | ||
|
|
||
| field = self._get_field_by_name("tier", CertificateNode) | ||
| result = field.base_resolver.wrapped_func(None, mock_cert) | ||
|
|
||
| assert result == "Level 1" | ||
| mock_cert.get_tier_display.assert_called_once() | ||
|
|
||
| def test_is_verified_resolver_active(self): | ||
| """Test is_verified returns True when certificate is verified.""" | ||
| mock_cert = Mock() | ||
| mock_cert.is_verified = True | ||
|
|
||
| field = self._get_field_by_name("is_verified", CertificateNode) | ||
| result = field.base_resolver.wrapped_func(None, mock_cert) | ||
|
|
||
| assert result is True | ||
|
|
||
| def test_is_verified_resolver_not_verified(self): | ||
| """Test is_verified returns False when certificate is not verified.""" | ||
| mock_cert = Mock() | ||
| mock_cert.is_verified = False | ||
|
|
||
| field = self._get_field_by_name("is_verified", CertificateNode) | ||
| result = field.base_resolver.wrapped_func(None, mock_cert) | ||
|
|
||
| assert result is False | ||
|
|
||
| def test_github_user_resolver(self): | ||
| """Test github_user resolver returns the related github_user instance.""" | ||
| mock_user = Mock() | ||
| mock_cert = Mock() | ||
| mock_cert.github_user = mock_user | ||
|
|
||
| field = self._get_field_by_name("github_user", CertificateNode) | ||
| result = field.base_resolver.wrapped_func(None, mock_cert) | ||
|
|
||
| assert result == mock_user |
76 changes: 76 additions & 0 deletions
76
backend/tests/unit/apps/owasp/api/internal/queries/certificate_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
| from django.core.exceptions import ValidationError | ||
|
|
||
| from apps.owasp.api.internal.queries.certificate import CertificateQuery | ||
| from apps.owasp.models.crp.certificate import Certificate | ||
|
|
||
|
|
||
| class TestCertificateQuery: | ||
| """Test suite for CertificateQuery.""" | ||
|
|
||
| def test_has_strawberry_definition(self): | ||
| """Test that CertificateQuery has valid Strawberry field definitions.""" | ||
| assert hasattr(CertificateQuery, "__strawberry_definition__") | ||
| field_names = [field.name for field in CertificateQuery.__strawberry_definition__.fields] | ||
| assert "certificate" in field_names | ||
| assert "my_certificates" in field_names | ||
|
|
||
| @patch("apps.owasp.models.crp.certificate.Certificate.objects.select_related") | ||
| def test_certificate_found(self, mock_select_related): | ||
| """Test certificate resolution when certificate exists.""" | ||
| mock_cert = MagicMock(spec=Certificate) | ||
| mock_select_related.return_value.get.return_value = mock_cert | ||
|
|
||
| result = CertificateQuery().certificate("CERT12345678") | ||
|
|
||
| mock_select_related.assert_called_once_with("github_user") | ||
| mock_select_related.return_value.get.assert_called_once_with(id="CERT12345678") | ||
| assert result == mock_cert | ||
|
|
||
| @pytest.mark.parametrize( | ||
| "exception", | ||
| [ | ||
| Certificate.DoesNotExist(), | ||
| ValidationError("Invalid ID format"), | ||
| ValueError("Invalid value"), | ||
| ], | ||
| ) | ||
| @patch("apps.owasp.models.crp.certificate.Certificate.objects.select_related") | ||
| def test_certificate_not_found_or_invalid(self, mock_select_related, exception): | ||
| """Test certificate resolution returns None when not found or on validation error.""" | ||
| mock_select_related.return_value.get.side_effect = exception | ||
|
|
||
| result = CertificateQuery().certificate("INVALID_ID") | ||
|
|
||
| assert result is None | ||
|
|
||
| def test_my_certificates_user_without_github_user(self): | ||
| """Test my_certificates returns empty list when user has no github_user.""" | ||
| info = MagicMock() | ||
| info.context.request.user = MagicMock(spec=[]) # user has no github_user attr | ||
|
|
||
| result = CertificateQuery().my_certificates(info) | ||
|
|
||
| assert result == [] | ||
|
|
||
| @patch("apps.owasp.models.crp.certificate.Certificate.objects.select_related") | ||
| def test_my_certificates_returns_active_certificates(self, mock_select_related): | ||
| """Test my_certificates returns the user's active certificates.""" | ||
| info = MagicMock() | ||
| mock_github_user = MagicMock() | ||
| info.context.request.user.github_user = mock_github_user | ||
|
|
||
| mock_certs = [MagicMock(spec=Certificate)] | ||
| mock_qs = MagicMock() | ||
| mock_select_related.return_value = mock_qs | ||
| mock_qs.filter.return_value = mock_qs | ||
| mock_qs.order_by.return_value = mock_certs | ||
|
|
||
| result = CertificateQuery().my_certificates(info) | ||
|
|
||
| mock_select_related.assert_called_once_with("github_user") | ||
| mock_qs.filter.assert_called_once_with(github_user=mock_github_user, is_revoked=False) | ||
| mock_qs.order_by.assert_called_once_with("-issued_at") | ||
| assert result == mock_certs |
77 changes: 77 additions & 0 deletions
77
backend/tests/unit/apps/owasp/management/commands/owasp_crp_recalculate_scores_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| from io import StringIO | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| import pytest | ||
| from django.core.management import call_command | ||
| from django.core.management.base import CommandError | ||
|
|
||
| COMMAND_PATH = "apps.owasp.management.commands.owasp_crp_recalculate_scores" | ||
|
|
||
|
|
||
| class TestOwaspCrpRecalculateScores: | ||
| """Test suite for the owasp_crp_recalculate_scores management command.""" | ||
|
|
||
| @patch(f"{COMMAND_PATH}.ContributionScoreCalculator") | ||
| def test_handle_success(self, mock_calculator_class): | ||
| """Test successful score recalculation with no failures.""" | ||
| mock_calculator = MagicMock() | ||
| mock_calculator.recalculate_all.return_value = { | ||
| "total": 10, | ||
| "created": 3, | ||
| "updated": 7, | ||
| "failed_count": 0, | ||
| } | ||
| mock_calculator_class.return_value = mock_calculator | ||
|
|
||
| out = StringIO() | ||
| call_command("owasp_crp_recalculate_scores", stdout=out) | ||
|
|
||
| mock_calculator_class.assert_called_once() | ||
| mock_calculator.recalculate_all.assert_called_once() | ||
|
|
||
| output = out.getvalue() | ||
| assert "Starting score recalculation for all users..." in output | ||
| assert "Score recalculation complete:" in output | ||
| assert "- Total users: 10" in output | ||
| assert "- Created: 3" in output | ||
| assert "- Updated: 7" in output | ||
| assert "- Failed: 0" in output | ||
|
|
||
| @patch(f"{COMMAND_PATH}.ContributionScoreCalculator") | ||
| def test_handle_with_failures(self, mock_calculator_class): | ||
| """Test score recalculation when certificate issuance failures occur.""" | ||
| mock_calculator = MagicMock() | ||
| mock_calculator.recalculate_all.return_value = { | ||
| "total": 5, | ||
| "created": 1, | ||
| "updated": 2, | ||
| "failed_count": 2, | ||
| "failures": [("alice", "Certificate error"), ("bob", "Network error")], | ||
| } | ||
| mock_calculator_class.return_value = mock_calculator | ||
|
|
||
| out = StringIO() | ||
| with pytest.raises(CommandError, match=r"Failed to issue certificates for 2 user\(s\)"): | ||
| call_command("owasp_crp_recalculate_scores", stdout=out) | ||
|
|
||
| output = out.getvalue() | ||
| assert "Failed to issue certificates for: alice, bob" in output | ||
|
|
||
| @patch(f"{COMMAND_PATH}.ContributionScoreCalculator") | ||
| def test_handle_with_failures_no_failures_list(self, mock_calculator_class): | ||
| """Test score recalculation when failed_count > 0 but failures key is missing.""" | ||
| mock_calculator = MagicMock() | ||
| mock_calculator.recalculate_all.return_value = { | ||
| "total": 2, | ||
| "created": 0, | ||
| "updated": 1, | ||
| "failed_count": 1, | ||
| } | ||
| mock_calculator_class.return_value = mock_calculator | ||
|
|
||
| out = StringIO() | ||
| with pytest.raises(CommandError, match=r"Failed to issue certificates for 1 user\(s\)"): | ||
| call_command("owasp_crp_recalculate_scores", stdout=out) | ||
|
|
||
| output = out.getvalue() | ||
| assert "Failed to issue certificates for: " in output | ||
Empty file.
114 changes: 114 additions & 0 deletions
114
backend/tests/unit/apps/owasp/models/crp/certificate_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| from unittest.mock import MagicMock, patch | ||
|
anurag2787 marked this conversation as resolved.
|
||
|
|
||
| import pytest | ||
|
|
||
| from apps.github.models.user import User | ||
| from apps.owasp.exceptions import CertificateIssuanceError | ||
| from apps.owasp.models.crp.certificate import ( | ||
| CERTIFICATE_ID_ALPHABET, | ||
| CERTIFICATE_ID_LENGTH, | ||
| Certificate, | ||
| generate_certificate_id, | ||
| ) | ||
| from apps.owasp.models.crp.recognition_enums import TierChoices | ||
|
|
||
| MODEL_PATH = "apps.owasp.models.crp.certificate" | ||
|
|
||
|
|
||
| class TestCertificateModel: | ||
| """Test suite for Certificate model.""" | ||
|
|
||
| def test_generate_certificate_id(self): | ||
| """Test generate_certificate_id produces a 12-char string from ALPHABET.""" | ||
| cert_id = generate_certificate_id() | ||
| assert len(cert_id) == CERTIFICATE_ID_LENGTH | ||
| assert all(c in CERTIFICATE_ID_ALPHABET for c in cert_id) | ||
|
|
||
| def test_str_representation_active(self): | ||
| """Test __str__ for active certificate.""" | ||
| user = User(login="john_doe") | ||
| cert = Certificate(github_user=user, tier=TierChoices.LEVEL_1, is_revoked=False) | ||
|
|
||
| assert str(cert) == "john_doe - LEVEL_1 Certificate (Active)" | ||
|
|
||
| def test_str_representation_revoked(self): | ||
| """Test __str__ for revoked certificate.""" | ||
| user = User(login="jane_doe") | ||
| cert = Certificate(github_user=user, tier=TierChoices.LEVEL_2, is_revoked=True) | ||
|
|
||
| assert str(cert) == "jane_doe - LEVEL_2 Certificate (Revoked)" | ||
|
|
||
| @patch("django.db.transaction.Atomic.__enter__", return_value=None) | ||
| @patch("django.db.transaction.Atomic.__exit__", return_value=None) | ||
| @patch(f"{MODEL_PATH}.Certificate.objects") | ||
| @patch(f"{MODEL_PATH}.User.objects") | ||
| def test_issue_certificate_already_exists( | ||
| self, mock_user_objects, mock_cert_objects, mock_exit, mock_enter | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ): | ||
| """Test issue_certificate returns early if active certificate already exists.""" | ||
| user = User(id=1, login="john_doe") | ||
| mock_user_objects.select_for_update.return_value.get.return_value = user | ||
| mock_cert_objects.filter.return_value.exists.return_value = True | ||
|
|
||
| Certificate.issue_certificate(user, 150, TierChoices.LEVEL_2) | ||
|
|
||
| mock_cert_objects.filter.assert_called_once_with( | ||
| github_user=user, tier=TierChoices.LEVEL_2, is_revoked=False | ||
| ) | ||
|
|
||
| @patch("django.db.transaction.Atomic.__enter__", return_value=None) | ||
| @patch("django.db.transaction.Atomic.__exit__", return_value=None) | ||
| @patch(f"{MODEL_PATH}.Certificate.objects") | ||
| @patch(f"{MODEL_PATH}.CertificateProviderFactory") | ||
| @patch(f"{MODEL_PATH}.User.objects") | ||
| def test_issue_certificate_provider_resolution_error( | ||
| self, mock_user_objects, mock_factory, mock_cert_objects, mock_exit, mock_enter | ||
| ): | ||
| """Test issue_certificate raises on provider resolution error.""" | ||
| user = User(id=1, login="john_doe") | ||
| mock_user_objects.select_for_update.return_value.get.return_value = user | ||
| mock_cert_objects.filter.return_value.exists.return_value = False | ||
| mock_factory.get_provider.side_effect = ValueError("Unknown provider") | ||
|
|
||
| with pytest.raises(CertificateIssuanceError): | ||
| Certificate.issue_certificate(user, 150, TierChoices.LEVEL_2) | ||
|
|
||
| @patch("django.db.transaction.Atomic.__enter__", return_value=None) | ||
| @patch("django.db.transaction.Atomic.__exit__", return_value=None) | ||
| @patch(f"{MODEL_PATH}.Certificate.objects") | ||
| @patch(f"{MODEL_PATH}.CertificateProviderFactory") | ||
| @patch(f"{MODEL_PATH}.User.objects") | ||
| def test_issue_certificate_provider_issuance_exception( | ||
| self, mock_user_objects, mock_factory, mock_cert_objects, mock_exit, mock_enter | ||
| ): | ||
| """Test issue_certificate raises when provider issuance fails.""" | ||
| user = User(id=1, login="test_user") | ||
| mock_user_objects.select_for_update.return_value.get.return_value = user | ||
| mock_cert_objects.filter.return_value.exists.return_value = False | ||
|
|
||
| mock_provider = MagicMock() | ||
| mock_provider.issue_certificate.side_effect = RuntimeError("PDF generation failed") | ||
| mock_factory.get_provider.return_value = mock_provider | ||
|
|
||
| with pytest.raises(CertificateIssuanceError): | ||
| Certificate.issue_certificate(user, 150, TierChoices.LEVEL_2) | ||
|
|
||
| @patch("django.db.transaction.Atomic.__enter__", return_value=None) | ||
| @patch("django.db.transaction.Atomic.__exit__", return_value=None) | ||
| @patch(f"{MODEL_PATH}.Certificate.objects") | ||
| @patch(f"{MODEL_PATH}.CertificateProviderFactory") | ||
| @patch(f"{MODEL_PATH}.User.objects") | ||
| def test_issue_certificate_success( | ||
| self, mock_user_objects, mock_factory, mock_cert_objects, mock_exit, mock_enter | ||
| ): | ||
| """Test successful certificate issuance.""" | ||
| user = User(id=1, login="test_user") | ||
| mock_user_objects.select_for_update.return_value.get.return_value = user | ||
| mock_cert_objects.filter.return_value.exists.return_value = False | ||
|
|
||
| mock_provider = MagicMock() | ||
| mock_factory.get_provider.return_value = mock_provider | ||
|
|
||
| Certificate.issue_certificate(user, 150, TierChoices.LEVEL_2) | ||
|
|
||
| mock_provider.issue_certificate.assert_called_once_with(user, 150, TierChoices.LEVEL_2) | ||
14 changes: 14 additions & 0 deletions
14
backend/tests/unit/apps/owasp/models/crp/contribution_score_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| from apps.github.models.user import User | ||
| from apps.owasp.models.crp.contribution_score import ContributionScore | ||
| from apps.owasp.models.crp.recognition_enums import TierChoices | ||
|
|
||
|
|
||
| class TestContributionScoreModel: | ||
| """Test suite for ContributionScore model.""" | ||
|
|
||
| def test_str_representation(self): | ||
| """Test __str__ for ContributionScore.""" | ||
| user = User(login="alice_dev") | ||
| score = ContributionScore(github_user=user, tier=TierChoices.LEVEL_3, value=350) | ||
|
|
||
| assert str(score) == "alice_dev - LEVEL_3 (350 points)" |
24 changes: 24 additions & 0 deletions
24
backend/tests/unit/apps/owasp/models/crp/leaderboard_snapshot_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| from datetime import date | ||
|
|
||
| from apps.github.models.user import User | ||
| from apps.owasp.models.crp.leaderboard_snapshot import LeaderboardSnapshot | ||
|
|
||
|
|
||
| class TestLeaderboardSnapshotModel: | ||
| """Test suite for LeaderboardSnapshot model.""" | ||
|
|
||
| def test_str_representation(self): | ||
| """Test __str__ for LeaderboardSnapshot.""" | ||
| user = User(login="bob_coder") | ||
| snapshot = LeaderboardSnapshot( | ||
| github_user=user, | ||
| global_rank=5, | ||
| project_rank=2, | ||
| chapter_rank=1, | ||
| snapshot_date=date(2026, 8, 1), | ||
| ) | ||
|
|
||
| assert ( | ||
| str(snapshot) | ||
| == "bob_coder - Global Rank: 5, Project Rank: 2, Chapter Rank: 1 (2026-08-01)" | ||
| ) |
11 changes: 11 additions & 0 deletions
11
backend/tests/unit/apps/owasp/models/crp/scoring_weight_test.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| from apps.owasp.models.crp.recognition_enums import EventTypeChoices | ||
| from apps.owasp.models.crp.scoring_weight import ScoringWeight | ||
|
|
||
|
|
||
| class TestScoringWeightModel: | ||
| """Test suite for ScoringWeight model.""" | ||
|
|
||
| def test_str_representation(self): | ||
| """Test __str__ for ScoringWeight using real choice display.""" | ||
| weight = ScoringWeight(event_type=EventTypeChoices.PR_MERGED, score=25) | ||
| assert str(weight) == "Pull Request Merged - 25 points" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.