diff --git a/backend/apps/owasp/admin/__init__.py b/backend/apps/owasp/admin/__init__.py index 261225145a..7baa31603e 100644 --- a/backend/apps/owasp/admin/__init__.py +++ b/backend/apps/owasp/admin/__init__.py @@ -16,6 +16,7 @@ from .project import ProjectAdmin from .project_health_metrics import ProjectHealthMetricsAdmin from .snapshot import SnapshotAdmin +from .snapshot_subscription import SnapshotSubscriptionAdmin from .sponsor import SponsorAdmin admin.site.register(ProjectHealthRequirements) diff --git a/backend/apps/owasp/admin/snapshot_subscription.py b/backend/apps/owasp/admin/snapshot_subscription.py new file mode 100644 index 0000000000..c0784bd961 --- /dev/null +++ b/backend/apps/owasp/admin/snapshot_subscription.py @@ -0,0 +1,44 @@ +"""Admin registration for SnapshotSubscription model.""" + +from django.contrib import admin + +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +class SnapshotSubscriptionAdmin(admin.ModelAdmin): + """Admin for SnapshotSubscription model.""" + + list_display = ("user", "frequency", "is_active", "created_at", "updated_at") + list_filter = ("frequency", "is_active", "created_at") + search_fields = ("user__email", "user__username") + raw_id_fields = ("user",) + readonly_fields = ("unsubscribe_token", "created_at", "updated_at") + + fieldsets = ( + (None, {"fields": ("user", "frequency", "is_active")}), + ( + "Content Preferences", + { + "fields": ( + "include_chapters", + "include_events", + "include_issues", + "include_posts", + "include_projects", + "include_pull_requests", + "include_releases", + "include_users", + ), + }, + ), + ( + "System", + { + "fields": ("unsubscribe_token", "created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + +admin.site.register(SnapshotSubscription, SnapshotSubscriptionAdmin) diff --git a/backend/apps/owasp/api/internal/mutations/__init__.py b/backend/apps/owasp/api/internal/mutations/__init__.py new file mode 100644 index 0000000000..d04e90ae87 --- /dev/null +++ b/backend/apps/owasp/api/internal/mutations/__init__.py @@ -0,0 +1,10 @@ +"""OWASP app mutations.""" + +import strawberry + +from .snapshot_subscription import SnapshotSubscriptionMutations + + +@strawberry.type +class OwaspMutations(SnapshotSubscriptionMutations): + """OWASP mutations.""" diff --git a/backend/apps/owasp/api/internal/mutations/snapshot_subscription.py b/backend/apps/owasp/api/internal/mutations/snapshot_subscription.py new file mode 100644 index 0000000000..3ce61808b2 --- /dev/null +++ b/backend/apps/owasp/api/internal/mutations/snapshot_subscription.py @@ -0,0 +1,188 @@ +"""OWASP snapshot subscription GraphQL mutations.""" + +import strawberry +from strawberry.types import Info + +from apps.nest.api.internal.permissions import IsAuthenticated +from apps.owasp.api.internal.nodes.snapshot_subscription import SnapshotSubscriptionNode +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +@strawberry.input +class CreateSnapshotSubscriptionInput: + """Input for creating a snapshot subscription.""" + + frequency: str = "weekly" + include_chapters: bool = True + include_events: bool = True + include_issues: bool = True + include_posts: bool = True + include_projects: bool = True + include_pull_requests: bool = True + include_releases: bool = True + include_users: bool = True + + +@strawberry.input +class UpdateSnapshotSubscriptionInput: + """Input for updating a snapshot subscription.""" + + frequency: str | None = None + include_chapters: bool | None = None + include_events: bool | None = None + include_issues: bool | None = None + include_posts: bool | None = None + include_projects: bool | None = None + include_pull_requests: bool | None = None + include_releases: bool | None = None + include_users: bool | None = None + + +@strawberry.type +class SnapshotSubscriptionResult: + """Result payload for snapshot subscription mutations.""" + + ok: bool + message: str + subscription: SnapshotSubscriptionNode | None = None + + +@strawberry.type +class SnapshotSubscriptionMutations: + """GraphQL mutations for snapshot subscription management.""" + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + def create_snapshot_subscription( + self, + info: Info, + input_data: CreateSnapshotSubscriptionInput, + ) -> SnapshotSubscriptionResult: + """Create a new snapshot subscription for the logged-in user.""" + user = info.context.request.user + + if SnapshotSubscription.objects.filter(user=user).exists(): + return SnapshotSubscriptionResult( + ok=False, + message="Subscription already exists.", + ) + + if input_data.frequency not in dict(SnapshotSubscription.Frequency.choices): + return SnapshotSubscriptionResult( + ok=False, + message="Invalid frequency. Must be 'weekly' or 'monthly'.", + ) + + subscription = SnapshotSubscription.objects.create( + user=user, + frequency=input_data.frequency, + include_chapters=input_data.include_chapters, + include_events=input_data.include_events, + include_issues=input_data.include_issues, + include_posts=input_data.include_posts, + include_projects=input_data.include_projects, + include_pull_requests=input_data.include_pull_requests, + include_releases=input_data.include_releases, + include_users=input_data.include_users, + ) + + return SnapshotSubscriptionResult( + ok=True, + message="Subscription created successfully.", + subscription=subscription, + ) + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + def update_snapshot_subscription( + self, + info: Info, + input_data: UpdateSnapshotSubscriptionInput, + ) -> SnapshotSubscriptionResult: + """Update the logged-in user's snapshot subscription.""" + user = info.context.request.user + + try: + subscription = SnapshotSubscription.objects.get(user=user) + except SnapshotSubscription.DoesNotExist: + return SnapshotSubscriptionResult( + ok=False, + message="Subscription not found.", + ) + + if input_data.frequency is not None: + if input_data.frequency not in dict(SnapshotSubscription.Frequency.choices): + return SnapshotSubscriptionResult( + ok=False, + message="Invalid frequency. Must be 'weekly' or 'monthly'.", + ) + subscription.frequency = input_data.frequency + + fields = { + "include_chapters": input_data.include_chapters, + "include_events": input_data.include_events, + "include_issues": input_data.include_issues, + "include_posts": input_data.include_posts, + "include_projects": input_data.include_projects, + "include_pull_requests": input_data.include_pull_requests, + "include_releases": input_data.include_releases, + "include_users": input_data.include_users, + } + + for field_name, value in fields.items(): + if value is not None: + setattr(subscription, field_name, value) + + subscription.save() + + return SnapshotSubscriptionResult( + ok=True, + message="Subscription updated successfully.", + subscription=subscription, + ) + + @strawberry.mutation(permission_classes=[IsAuthenticated]) + def cancel_snapshot_subscription(self, info: Info) -> SnapshotSubscriptionResult: + """Cancel the logged-in user's snapshot subscription.""" + user = info.context.request.user + + try: + subscription = SnapshotSubscription.objects.get(user=user) + except SnapshotSubscription.DoesNotExist: + return SnapshotSubscriptionResult( + ok=False, + message="Subscription not found.", + ) + + subscription.is_active = False + subscription.save() + + return SnapshotSubscriptionResult( + ok=True, + message="Subscription cancelled successfully.", + subscription=subscription, + ) + + @strawberry.mutation + def unsubscribe_by_token(self, token: str) -> SnapshotSubscriptionResult: + """Unsubscribe using a token from an email link. No auth required.""" + try: + subscription = SnapshotSubscription.objects.get(unsubscribe_token=token) + except (SnapshotSubscription.DoesNotExist, ValueError): + return SnapshotSubscriptionResult( + ok=False, + message="Invalid unsubscribe token.", + ) + + if not subscription.is_active: + return SnapshotSubscriptionResult( + ok=False, + message="Subscription is already inactive.", + ) + + subscription.is_active = False + subscription.save() + + return SnapshotSubscriptionResult( + ok=True, + message="Successfully unsubscribed.", + subscription=subscription, + ) diff --git a/backend/apps/owasp/api/internal/nodes/snapshot_subscription.py b/backend/apps/owasp/api/internal/nodes/snapshot_subscription.py new file mode 100644 index 0000000000..d8bd76ca0f --- /dev/null +++ b/backend/apps/owasp/api/internal/nodes/snapshot_subscription.py @@ -0,0 +1,27 @@ +"""OWASP snapshot subscription GraphQL node.""" + +import strawberry +import strawberry_django + +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +@strawberry_django.type( + SnapshotSubscription, + fields=[ + "frequency", + "is_active", + "include_chapters", + "include_events", + "include_issues", + "include_posts", + "include_projects", + "include_pull_requests", + "include_releases", + "include_users", + "created_at", + "updated_at", + ], +) +class SnapshotSubscriptionNode(strawberry.relay.Node): + """Snapshot subscription node.""" diff --git a/backend/apps/owasp/api/internal/queries/__init__.py b/backend/apps/owasp/api/internal/queries/__init__.py index 3e27bea1d0..9622be1997 100644 --- a/backend/apps/owasp/api/internal/queries/__init__.py +++ b/backend/apps/owasp/api/internal/queries/__init__.py @@ -9,6 +9,7 @@ from .project import ProjectQuery from .project_health_metrics import ProjectHealthMetricsQuery from .snapshot import SnapshotQuery +from .snapshot_subscription import SnapshotSubscriptionQuery from .sponsor import SponsorQuery from .stats import StatsQuery @@ -23,6 +24,7 @@ class OwaspQuery( ProjectHealthMetricsQuery, ProjectQuery, SnapshotQuery, + SnapshotSubscriptionQuery, SponsorQuery, StatsQuery, ): diff --git a/backend/apps/owasp/api/internal/queries/snapshot_subscription.py b/backend/apps/owasp/api/internal/queries/snapshot_subscription.py new file mode 100644 index 0000000000..e6bfbae117 --- /dev/null +++ b/backend/apps/owasp/api/internal/queries/snapshot_subscription.py @@ -0,0 +1,25 @@ +"""OWASP snapshot subscription GraphQL queries.""" + +import strawberry +import strawberry_django +from strawberry.types import Info + +from apps.owasp.api.internal.nodes.snapshot_subscription import SnapshotSubscriptionNode +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +@strawberry.type +class SnapshotSubscriptionQuery: + """Snapshot subscription queries.""" + + @strawberry_django.field + def my_subscription(self, info: Info) -> SnapshotSubscriptionNode | None: + """Resolve the current user's snapshot subscription.""" + user = info.context.request.user + if not user.is_authenticated: + return None + + try: + return SnapshotSubscription.objects.get(user=user) + except SnapshotSubscription.DoesNotExist: + return None diff --git a/backend/apps/owasp/migrations/0075_snapshotsubscription.py b/backend/apps/owasp/migrations/0075_snapshotsubscription.py new file mode 100644 index 0000000000..d8e8563720 --- /dev/null +++ b/backend/apps/owasp/migrations/0075_snapshotsubscription.py @@ -0,0 +1,66 @@ +# Generated by Django 6.0.6 on 2026-06-16 14:45 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0074_rename_snapshot_m2m_fields"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SnapshotSubscription", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "frequency", + models.CharField( + choices=[("weekly", "Weekly"), ("monthly", "Monthly")], + default="weekly", + max_length=10, + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "unsubscribe_token", + models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ("include_chapters", models.BooleanField(default=True)), + ("include_events", models.BooleanField(default=True)), + ("include_issues", models.BooleanField(default=True)), + ("include_posts", models.BooleanField(default=True)), + ("include_projects", models.BooleanField(default=True)), + ("include_pull_requests", models.BooleanField(default=True)), + ("include_releases", models.BooleanField(default=True)), + ("include_users", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="snapshot_subscription", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name_plural": "Snapshot Subscriptions", + "db_table": "owasp_snapshot_subscriptions", + "indexes": [ + models.Index(fields=["is_active"], name="owasp_sub_active_idx"), + ], + }, + ), + ] diff --git a/backend/apps/owasp/models/__init__.py b/backend/apps/owasp/models/__init__.py index 3cbb120b8b..caa983642d 100644 --- a/backend/apps/owasp/models/__init__.py +++ b/backend/apps/owasp/models/__init__.py @@ -11,4 +11,5 @@ from .project_health_metrics import ProjectHealthMetrics from .project_health_requirements import ProjectHealthRequirements from .snapshot import Snapshot +from .snapshot_subscription import SnapshotSubscription from .sponsor import Sponsor diff --git a/backend/apps/owasp/models/snapshot_subscription.py b/backend/apps/owasp/models/snapshot_subscription.py new file mode 100644 index 0000000000..45c727042c --- /dev/null +++ b/backend/apps/owasp/models/snapshot_subscription.py @@ -0,0 +1,85 @@ +"""OWASP app snapshot subscription model.""" + +import uuid + +from django.db import models + +from apps.nest.models import User + + +class SnapshotSubscription(models.Model): + """Model representing a user's subscription to snapshot digest emails.""" + + class Meta: + """Model options.""" + + db_table = "owasp_snapshot_subscriptions" + verbose_name_plural = "Snapshot Subscriptions" + indexes = [ + models.Index(fields=["is_active"], name="owasp_sub_active_idx"), + ] + + class Frequency(models.TextChoices): + """Subscription frequency choices.""" + + WEEKLY = "weekly", "Weekly" + MONTHLY = "monthly", "Monthly" + + class Status(models.TextChoices): + """Subscription status choices.""" + + ACTIVE = "active", "Active" + INACTIVE = "inactive", "Inactive" + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="snapshot_subscription", + ) + frequency = models.CharField( + max_length=10, + choices=Frequency.choices, + default=Frequency.WEEKLY, + ) + is_active = models.BooleanField(default=True) + unsubscribe_token = models.UUIDField( + default=uuid.uuid4, + unique=True, + editable=False, + ) + + # Content preferences + include_chapters = models.BooleanField(default=True) + include_events = models.BooleanField(default=True) + include_issues = models.BooleanField(default=True) + include_posts = models.BooleanField(default=True) + include_projects = models.BooleanField(default=True) + include_pull_requests = models.BooleanField(default=True) + include_releases = models.BooleanField(default=True) + include_users = models.BooleanField(default=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + """Return a string representation.""" + status = ( + SnapshotSubscription.Status.ACTIVE + if self.is_active + else SnapshotSubscription.Status.INACTIVE + ) + return f"{self.user} ({self.frequency}, {status})" + + @property + def content_preferences(self): + """Return a dictionary of content preference settings.""" + return { + "chapters": self.include_chapters, + "events": self.include_events, + "issues": self.include_issues, + "posts": self.include_posts, + "projects": self.include_projects, + "pull_requests": self.include_pull_requests, + "releases": self.include_releases, + "users": self.include_users, + } diff --git a/backend/data/nest.dump b/backend/data/nest.dump index 2c0903bb20..429b81683c 100644 Binary files a/backend/data/nest.dump and b/backend/data/nest.dump differ diff --git a/backend/settings/graphql.py b/backend/settings/graphql.py index ed7efe7258..b777ab839c 100644 --- a/backend/settings/graphql.py +++ b/backend/settings/graphql.py @@ -20,6 +20,7 @@ ProgramQuery, ) from apps.nest.api.internal.mutations import NestMutations +from apps.owasp.api.internal.mutations import OwaspMutations from apps.owasp.api.internal.queries import OwaspQuery @@ -28,6 +29,7 @@ class Mutation( ApiMutations, ModuleMutation, NestMutations, + OwaspMutations, ProgramMutation, ): """Schema mutations.""" diff --git a/backend/tests/unit/apps/owasp/admin/snapshot_subscription_test.py b/backend/tests/unit/apps/owasp/admin/snapshot_subscription_test.py new file mode 100644 index 0000000000..b71c7983a8 --- /dev/null +++ b/backend/tests/unit/apps/owasp/admin/snapshot_subscription_test.py @@ -0,0 +1,45 @@ +"""Tests for snapshot subscription admin.""" + +from django.contrib.admin.sites import AdminSite + +from apps.owasp.admin.snapshot_subscription import SnapshotSubscriptionAdmin +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +class TestSnapshotSubscriptionAdmin: + """Test SnapshotSubscriptionAdmin configuration.""" + + def test_admin_configuration(self): + """Test admin configuration matches expected setup.""" + site = AdminSite() + admin = SnapshotSubscriptionAdmin(SnapshotSubscription, site) + + assert admin.list_display == ( + "user", + "frequency", + "is_active", + "created_at", + "updated_at", + ) + assert admin.list_filter == ("frequency", "is_active", "created_at") + assert admin.search_fields == ("user__email", "user__username") + assert admin.raw_id_fields == ("user",) + assert admin.readonly_fields == ("unsubscribe_token", "created_at", "updated_at") + + # Check fieldsets structure + assert len(admin.fieldsets) == 3 + + # Check Content Preferences fieldset + preferences_fieldset = admin.fieldsets[1] + assert preferences_fieldset[0] == "Content Preferences" + + assert preferences_fieldset[1]["fields"] == ( + "include_chapters", + "include_events", + "include_issues", + "include_posts", + "include_projects", + "include_pull_requests", + "include_releases", + "include_users", + ) diff --git a/backend/tests/unit/apps/owasp/api/internal/mutations/__init__.py b/backend/tests/unit/apps/owasp/api/internal/mutations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/unit/apps/owasp/api/internal/mutations/snapshot_subscription_test.py b/backend/tests/unit/apps/owasp/api/internal/mutations/snapshot_subscription_test.py new file mode 100644 index 0000000000..2a14e041df --- /dev/null +++ b/backend/tests/unit/apps/owasp/api/internal/mutations/snapshot_subscription_test.py @@ -0,0 +1,221 @@ +"""Tests for snapshot subscription GraphQL mutations.""" + +import uuid +from unittest.mock import MagicMock, patch + +import pytest + +from apps.owasp.api.internal.mutations.snapshot_subscription import ( + CreateSnapshotSubscriptionInput, + SnapshotSubscriptionMutations, + SnapshotSubscriptionResult, + UpdateSnapshotSubscriptionInput, +) +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + +MOCK_TOKEN = "mock-unsubscribe-token" # noqa: S105 + + +def mock_info() -> MagicMock: + """Return a mocked Info object.""" + info = MagicMock() + info.context = MagicMock() + info.context.request = MagicMock() + info.context.request.user = MagicMock() + info.context.request.user.is_authenticated = True + return info + + +class TestSnapshotSubscriptionResult: + """Test cases for SnapshotSubscriptionResult.""" + + def test_result_ok(self): + result = SnapshotSubscriptionResult(ok=True, message="Success") + assert result.ok + assert result.message == "Success" + + def test_result_not_ok(self): + result = SnapshotSubscriptionResult(ok=False, message="Failed") + assert not result.ok + assert result.message == "Failed" + + +class TestCreateSnapshotSubscription: + """Test cases for createSnapshotSubscription mutation.""" + + @pytest.fixture + def mutations(self): + return SnapshotSubscriptionMutations() + + def test_duplicate_subscription(self, mutations): + """Test create fails when subscription already exists.""" + info = mock_info() + input_data = CreateSnapshotSubscriptionInput() + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.filter.return_value.exists.return_value = True + result = mutations.create_snapshot_subscription(info, input_data=input_data) + assert not result.ok + assert result.message == "Subscription already exists." + + def test_invalid_frequency(self, mutations): + """Test create fails with invalid frequency.""" + info = mock_info() + input_data = CreateSnapshotSubscriptionInput(frequency="daily") + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.filter.return_value.exists.return_value = False + result = mutations.create_snapshot_subscription(info, input_data=input_data) + assert not result.ok + assert "Invalid frequency" in result.message + + def test_success(self, mutations): + """Test successful subscription creation.""" + info = mock_info() + input_data = CreateSnapshotSubscriptionInput(frequency="weekly") + mock_sub = MagicMock(spec=SnapshotSubscription) + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.filter.return_value.exists.return_value = False + mock_objects.create.return_value = mock_sub + result = mutations.create_snapshot_subscription(info, input_data=input_data) + assert result.ok + assert result.message == "Subscription created successfully." + assert result.subscription == mock_sub + + +class TestUpdateSnapshotSubscription: + """Test cases for updateSnapshotSubscription mutation.""" + + @pytest.fixture + def mutations(self): + return SnapshotSubscriptionMutations() + + def test_not_found(self, mutations): + """Test update fails when subscription doesn't exist.""" + info = mock_info() + input_data = UpdateSnapshotSubscriptionInput() + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.side_effect = SnapshotSubscription.DoesNotExist + result = mutations.update_snapshot_subscription(info, input_data=input_data) + assert not result.ok + assert result.message == "Subscription not found." + + def test_invalid_frequency(self, mutations): + """Test update fails with invalid frequency.""" + info = mock_info() + input_data = UpdateSnapshotSubscriptionInput(frequency="daily") + mock_sub = MagicMock(spec=SnapshotSubscription) + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.return_value = mock_sub + result = mutations.update_snapshot_subscription(info, input_data=input_data) + assert not result.ok + assert "Invalid frequency" in result.message + + def test_success(self, mutations): + """Test successful subscription update.""" + info = mock_info() + input_data = UpdateSnapshotSubscriptionInput(frequency="monthly", include_chapters=False) + mock_sub = MagicMock(spec=SnapshotSubscription) + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.return_value = mock_sub + result = mutations.update_snapshot_subscription(info, input_data=input_data) + assert result.ok + assert result.message == "Subscription updated successfully." + mock_sub.save.assert_called_once() + + +class TestCancelSnapshotSubscription: + """Test cases for cancelSnapshotSubscription mutation.""" + + @pytest.fixture + def mutations(self): + return SnapshotSubscriptionMutations() + + def test_not_found(self, mutations): + """Test cancel fails when subscription doesn't exist.""" + info = mock_info() + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.side_effect = SnapshotSubscription.DoesNotExist + result = mutations.cancel_snapshot_subscription(info) + assert not result.ok + assert result.message == "Subscription not found." + + def test_success(self, mutations): + """Test successful subscription cancellation.""" + info = mock_info() + mock_sub = MagicMock(spec=SnapshotSubscription) + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.return_value = mock_sub + result = mutations.cancel_snapshot_subscription(info) + assert result.ok + assert result.message == "Subscription cancelled successfully." + assert mock_sub.is_active is False + mock_sub.save.assert_called_once() + + +class TestUnsubscribeByToken: + """Test cases for unsubscribeByToken mutation.""" + + @pytest.fixture + def mutations(self): + return SnapshotSubscriptionMutations() + + def test_invalid_token(self, mutations): + """Test unsubscribe fails with invalid token.""" + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.side_effect = SnapshotSubscription.DoesNotExist + result = mutations.unsubscribe_by_token(token=str(uuid.uuid4())) + assert not result.ok + assert result.message == "Invalid unsubscribe token." + + def test_malformed_token(self, mutations): + """Test unsubscribe fails with malformed token.""" + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.side_effect = ValueError + result = mutations.unsubscribe_by_token(token=MOCK_TOKEN) + assert not result.ok + assert result.message == "Invalid unsubscribe token." + + def test_already_inactive(self, mutations): + """Test unsubscribe fails when already inactive.""" + mock_sub = MagicMock(spec=SnapshotSubscription) + mock_sub.is_active = False + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.return_value = mock_sub + result = mutations.unsubscribe_by_token(token=str(uuid.uuid4())) + assert not result.ok + assert result.message == "Subscription is already inactive." + + def test_success(self, mutations): + """Test successful unsubscribe by token.""" + mock_sub = MagicMock(spec=SnapshotSubscription) + mock_sub.is_active = True + with patch( + "apps.owasp.api.internal.mutations.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.return_value = mock_sub + result = mutations.unsubscribe_by_token(token=str(uuid.uuid4())) + assert result.ok + assert result.message == "Successfully unsubscribed." + assert mock_sub.is_active is False + mock_sub.save.assert_called_once() diff --git a/backend/tests/unit/apps/owasp/api/internal/nodes/snapshot_subscription_test.py b/backend/tests/unit/apps/owasp/api/internal/nodes/snapshot_subscription_test.py new file mode 100644 index 0000000000..227787d50b --- /dev/null +++ b/backend/tests/unit/apps/owasp/api/internal/nodes/snapshot_subscription_test.py @@ -0,0 +1,33 @@ +"""Test cases for SnapshotSubscriptionNode.""" + +from apps.owasp.api.internal.nodes.snapshot_subscription import SnapshotSubscriptionNode +from tests.unit.apps.common.graphql_node_base_test import GraphQLNodeBaseTest + + +class TestSnapshotSubscriptionNode(GraphQLNodeBaseTest): + """Test cases for SnapshotSubscriptionNode.""" + + def test_snapshot_subscription_node_inheritance(self): + """Test SnapshotSubscriptionNode has strawberry definition.""" + assert hasattr(SnapshotSubscriptionNode, "__strawberry_definition__") + + def test_meta_configuration(self): + """Test expected fields are present.""" + field_names = { + field.name for field in SnapshotSubscriptionNode.__strawberry_definition__.fields + } + expected_field_names = { + "frequency", + "is_active", + "include_chapters", + "include_events", + "include_issues", + "include_posts", + "include_projects", + "include_pull_requests", + "include_releases", + "include_users", + "created_at", + "updated_at", + } + assert expected_field_names.issubset(field_names) diff --git a/backend/tests/unit/apps/owasp/api/internal/queries/snapshot_subscription_test.py b/backend/tests/unit/apps/owasp/api/internal/queries/snapshot_subscription_test.py new file mode 100644 index 0000000000..6e70b73b3e --- /dev/null +++ b/backend/tests/unit/apps/owasp/api/internal/queries/snapshot_subscription_test.py @@ -0,0 +1,59 @@ +"""Tests for snapshot subscription GraphQL queries.""" + +from unittest.mock import MagicMock, patch + +from apps.owasp.api.internal.queries.snapshot_subscription import SnapshotSubscriptionQuery +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +def mock_info(*, authenticated=True): + """Return a minimal mock of strawberry Info with request on context.""" + info = MagicMock() + info.context = MagicMock() + info.context.request = MagicMock() + info.context.request.user.is_authenticated = authenticated + return info + + +class TestSnapshotSubscriptionQuery: + """Test cases for SnapshotSubscriptionQuery.""" + + def setup_method(self): + """Set up test fixtures.""" + self.query = SnapshotSubscriptionQuery() + + def test_query_has_strawberry_definition(self): + """Check if SnapshotSubscriptionQuery has valid Strawberry definition.""" + assert hasattr(SnapshotSubscriptionQuery, "__strawberry_definition__") + + field_names = [ + field.name for field in SnapshotSubscriptionQuery.__strawberry_definition__.fields + ] + assert "my_subscription" in field_names + + def test_my_subscription_unauthenticated(self): + """Test mySubscription returns None for unauthenticated user.""" + info = mock_info(authenticated=False) + result = self.query.__class__.__dict__["my_subscription"](self.query, info=info) + assert result is None + + def test_my_subscription_not_found(self): + """Test mySubscription returns None when no subscription exists.""" + info = mock_info() + with patch( + "apps.owasp.api.internal.queries.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.side_effect = SnapshotSubscription.DoesNotExist + result = self.query.__class__.__dict__["my_subscription"](self.query, info=info) + assert result is None + + def test_my_subscription_found(self): + """Test mySubscription returns subscription when it exists.""" + info = mock_info() + mock_sub = MagicMock(spec=SnapshotSubscription) + with patch( + "apps.owasp.api.internal.queries.snapshot_subscription.SnapshotSubscription.objects" + ) as mock_objects: + mock_objects.get.return_value = mock_sub + result = self.query.__class__.__dict__["my_subscription"](self.query, info=info) + assert result == mock_sub diff --git a/backend/tests/unit/apps/owasp/models/snapshot_subscription_test.py b/backend/tests/unit/apps/owasp/models/snapshot_subscription_test.py new file mode 100644 index 0000000000..60906f4360 --- /dev/null +++ b/backend/tests/unit/apps/owasp/models/snapshot_subscription_test.py @@ -0,0 +1,85 @@ +"""Tests for snapshot subscription model.""" + +import uuid +from unittest.mock import MagicMock + +from django.test import SimpleTestCase + +from apps.owasp.models.snapshot_subscription import SnapshotSubscription + + +class TestSnapshotSubscription(SimpleTestCase): + """Test SnapshotSubscription model.""" + + def test_str_representation_active(self): + """Test string representation for active subscription.""" + sub = MagicMock(spec=SnapshotSubscription) + sub.user = MagicMock() + sub.frequency = SnapshotSubscription.Frequency.WEEKLY + sub.is_active = True + + result = SnapshotSubscription.__str__(sub) + assert result == f"{sub.user} (weekly, active)" + + def test_str_representation_inactive(self): + """Test string representation for inactive subscription.""" + sub = MagicMock(spec=SnapshotSubscription) + sub.user = MagicMock() + sub.frequency = SnapshotSubscription.Frequency.MONTHLY + sub.is_active = False + + result = SnapshotSubscription.__str__(sub) + assert result == f"{sub.user} (monthly, inactive)" + + def test_content_preferences_all_defaults(self): + """Test that content_preferences returns all True by default.""" + sub = SnapshotSubscription() + prefs = sub.content_preferences + assert prefs == { + "chapters": True, + "events": True, + "issues": True, + "posts": True, + "projects": True, + "pull_requests": True, + "releases": True, + "users": True, + } + + def test_content_preferences_custom(self): + """Test content_preferences with custom values.""" + sub = MagicMock(spec=SnapshotSubscription) + sub.include_chapters = False + sub.include_events = True + sub.include_issues = True + sub.include_posts = False + sub.include_projects = False + sub.include_pull_requests = True + sub.include_releases = True + sub.include_users = True + + prefs = SnapshotSubscription.content_preferences.fget(sub) + assert prefs == { + "chapters": False, + "events": True, + "issues": True, + "posts": False, + "projects": False, + "pull_requests": True, + "releases": True, + "users": True, + } + + def test_frequency_choices(self): + """Test frequency choices are correctly defined.""" + assert SnapshotSubscription.Frequency.WEEKLY == "weekly" + assert SnapshotSubscription.Frequency.MONTHLY == "monthly" + + def test_unsubscribe_token_defaults(self): + """Test that unsubscribe_token is a unique UUID for each instance.""" + first = SnapshotSubscription() + second = SnapshotSubscription() + + assert isinstance(first.unsubscribe_token, uuid.UUID) + assert isinstance(second.unsubscribe_token, uuid.UUID) + assert first.unsubscribe_token != second.unsubscribe_token diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index 0ec9aab002..f8ccdbb67e 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -385,10 +385,12 @@ export type ModuleNodeTaskDeadlineArgs = { export type Mutation = { __typename?: 'Mutation'; assignIssueToUser: ModuleNode; + cancelSnapshotSubscription: SnapshotSubscriptionResult; clearTaskDeadline: ModuleNode; createApiKey: CreateApiKeyResult; createModule: ModuleNode; createProgram: ProgramNode; + createSnapshotSubscription: SnapshotSubscriptionResult; deleteModule: Scalars['String']['output']; githubAuth: GitHubAuthResult; logoutUser: LogoutResult; @@ -396,9 +398,11 @@ export type Mutation = { revokeApiKey: RevokeApiKeyResult; setTaskDeadline: ModuleNode; unassignIssueFromUser: ModuleNode; + unsubscribeByToken: SnapshotSubscriptionResult; updateModule: ModuleNode; updateProgram: ProgramNode; updateProgramStatus: ProgramNode; + updateSnapshotSubscription: SnapshotSubscriptionResult; }; @@ -433,6 +437,19 @@ export type MutationCreateProgramArgs = { }; +export type MutationCreateSnapshotSubscriptionArgs = { + frequency?: Scalars['String']['input']; + includeChapters?: Scalars['Boolean']['input']; + includeEvents?: Scalars['Boolean']['input']; + includeIssues?: Scalars['Boolean']['input']; + includePosts?: Scalars['Boolean']['input']; + includeProjects?: Scalars['Boolean']['input']; + includePullRequests?: Scalars['Boolean']['input']; + includeReleases?: Scalars['Boolean']['input']; + includeUsers?: Scalars['Boolean']['input']; +}; + + export type MutationDeleteModuleArgs = { moduleKey: Scalars['String']['input']; programKey: Scalars['String']['input']; @@ -470,6 +487,11 @@ export type MutationUnassignIssueFromUserArgs = { }; +export type MutationUnsubscribeByTokenArgs = { + token: Scalars['String']['input']; +}; + + export type MutationUpdateModuleArgs = { inputData: UpdateModuleInput; }; @@ -484,6 +506,19 @@ export type MutationUpdateProgramStatusArgs = { inputData: UpdateProgramStatusInput; }; + +export type MutationUpdateSnapshotSubscriptionArgs = { + frequency?: InputMaybe; + includeChapters?: InputMaybe; + includeEvents?: InputMaybe; + includeIssues?: InputMaybe; + includePosts?: InputMaybe; + includeProjects?: InputMaybe; + includePullRequests?: InputMaybe; + includeReleases?: InputMaybe; + includeUsers?: InputMaybe; +}; + /** An object with a Globally Unique ID */ export type Node = { /** The Globally Unique ID of this object */ @@ -730,6 +765,7 @@ export type Query = { memberSnapshot?: Maybe; memberSnapshots: Array; myPrograms: PaginatedPrograms; + mySubscription?: Maybe; organization?: Maybe; project?: Maybe; /** List of project health metrics. */ @@ -1106,6 +1142,31 @@ export type SnapshotNodeUsersArgs = { offset?: Scalars['Int']['input']; }; +export type SnapshotSubscriptionNode = Node & { + __typename?: 'SnapshotSubscriptionNode'; + createdAt: Scalars['DateTime']['output']; + frequency: Scalars['String']['output']; + /** The Globally Unique ID of this object */ + id: Scalars['ID']['output']; + includeChapters: Scalars['Boolean']['output']; + includeEvents: Scalars['Boolean']['output']; + includeIssues: Scalars['Boolean']['output']; + includePosts: Scalars['Boolean']['output']; + includeProjects: Scalars['Boolean']['output']; + includePullRequests: Scalars['Boolean']['output']; + includeReleases: Scalars['Boolean']['output']; + includeUsers: Scalars['Boolean']['output']; + isActive: Scalars['Boolean']['output']; + updatedAt: Scalars['DateTime']['output']; +}; + +export type SnapshotSubscriptionResult = { + __typename?: 'SnapshotSubscriptionResult'; + message: Scalars['String']['output']; + ok: Scalars['Boolean']['output']; + subscription?: Maybe; +}; + export type SponsorNode = Node & { __typename?: 'SponsorNode'; /** The Globally Unique ID of this object */