Skip to content
2 changes: 1 addition & 1 deletion backend/src/apps/owasp/utils/score_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ def recalculate_user(self, user: User) -> dict[str, str | int | bool]:
},
)

Certificate.issue_certificate(user, total_score, tier)
Certificate.issue_certificate(user, total_score, TierChoices(tier))

logger.info(
"Recalculated score for %s: %s points (%s)",
Expand Down
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
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
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
Comment thread
anurag2787 marked this conversation as resolved.
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 backend/tests/unit/apps/owasp/models/crp/certificate_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from unittest.mock import MagicMock, patch
Comment thread
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
Comment thread
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)
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)"
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 backend/tests/unit/apps/owasp/models/crp/scoring_weight_test.py
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"
Loading
Loading