diff --git a/Makefile b/Makefile index 35bc019..b7165f4 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ pre_commit: test: @echo "🧪 Executando os testes..." - poetry run python src/manage.py test + poetry run python src/manage.py test src @echo "✅ Testes concluídos." worker: diff --git a/src/accounts/tests.py b/src/accounts/tests.py deleted file mode 100644 index a39b155..0000000 --- a/src/accounts/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/tests/__init__.py b/src/accounts/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to src/accounts/tests/__init__.py diff --git a/src/accounts/tests/helpers.py b/src/accounts/tests/helpers.py new file mode 100644 index 0000000..4389a58 --- /dev/null +++ b/src/accounts/tests/helpers.py @@ -0,0 +1,38 @@ +from typing import Unpack + +from django.contrib.auth.hashers import make_password +from model_bakery import baker + +from accounts.models import ( + HumanAgent, + User, +) +from accounts.typing import ( + AuthModel, + HumanAgentDTO, + UserDTO, +) +from config.faker import fake + + +def sample_user( + model: type[AuthModel] = User, + prepare: bool = False, + **kwargs: Unpack[UserDTO], +) -> AuthModel | list[AuthModel]: + kwargs.setdefault("name", fake.name) + kwargs.setdefault("email", fake.ascii_email) + + kwargs["password"] = make_password(kwargs.get("password", "password")) + + if prepare: + return baker.prepare(model, **kwargs) + + return baker.make(model, **kwargs) + + +def sample_human_agent( + prepare=False, + **kwargs: Unpack[HumanAgentDTO], +) -> HumanAgent | list[HumanAgent]: + return sample_user(HumanAgent, prepare, **kwargs) diff --git a/src/accounts/tests/test_contact_view_set.py b/src/accounts/tests/test_contact_view_set.py new file mode 100644 index 0000000..39dedbc --- /dev/null +++ b/src/accounts/tests/test_contact_view_set.py @@ -0,0 +1,63 @@ +from assets.helpers import configure_api_client + +from rest_framework.test import APITestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response + +from accounts.tests.helpers import sample_human_agent +from accounts.models import Contact +from channels.tests.helpers import sample_channel +from config.faker import fake + + +class TestContactViewSet(APITestCase): + def retrieve(self, contact_id: int = None, **kwargs) -> Response: + if contact_id is None: + contact = Contact.objects.create( + name=fake.name(), + channel=sample_channel(), + channel_identifier=str(fake.random_int()), + ) + contact_id = contact.id + url = reverse("contact-detail", kwargs={"pk": contact_id}) + return self.client.get(url, format="json", **kwargs) + + def list(self, filters=None, **kwargs) -> Response: + Contact.objects.create( + name=fake.name(), + channel=sample_channel(), + channel_identifier=str(fake.random_int()), + ) + Contact.objects.create( + name=fake.name(), + channel=sample_channel(), + channel_identifier=str(fake.random_int()), + ) + url = reverse("contact-list") + return self.client.get(url, filters) + + +class TestContactViewSetUnauthenticated(TestContactViewSet): + def test_retrieve_unauthenticated(self): + response = self.retrieve() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_list_unauthenticated(self): + response = self.list() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class TestContactViewSetAuthenticated(TestContactViewSet): + def setUp(self) -> None: + self.user = sample_human_agent() + configure_api_client(self.client, user=self.user) + + def test_retrieve_authenticated(self): + response = self.retrieve() + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_list_authenticated(self): + response = self.list() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 2) diff --git a/src/accounts/tests/test_human_agent_view_set.py b/src/accounts/tests/test_human_agent_view_set.py new file mode 100644 index 0000000..bcfa3f6 --- /dev/null +++ b/src/accounts/tests/test_human_agent_view_set.py @@ -0,0 +1,127 @@ +from assets.helpers import configure_api_client + +from rest_framework.test import APITestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.response import Response + +from accounts.tests.helpers import sample_human_agent +from accounts.models import HumanAgent +from config.faker import fake + + +class TestHumanAgentViewSet(APITestCase): + def create(self, payload: dict = None, **args) -> tuple[Response, dict]: + url = reverse("human-agent-list") + + if payload is None: + payload = { + "name": fake.name(), + "email": fake.ascii_email(), + } + + response = self.client.post(url, payload, format="json", **args) + return response, payload + + def retrieve(self, agent_id: int = None, **kwargs) -> Response: + if agent_id is None: + agent = sample_human_agent() + agent_id = agent.id + url = reverse("human-agent-detail", kwargs={"pk": agent_id}) + return self.client.get(url, format="json", **kwargs) + + def list(self, **kwargs) -> Response: + sample_human_agent(_quantity=3) + url = reverse("human-agent-list") + return self.client.get(url, **kwargs) + + def update( + self, agent: HumanAgent = None, payload: dict = None, **kwargs + ) -> tuple[Response, HumanAgent, dict]: + if agent is None: + agent = sample_human_agent() + url = reverse("human-agent-detail", kwargs={"pk": agent.id}) + + if payload is None: + payload = { + "name": fake.name(), + "email": fake.ascii_email(), + } + + response = self.client.put(url, payload, format="json", **kwargs) + agent.refresh_from_db() + return response, agent, payload + + def delete(self, agent: HumanAgent = None, **kwargs) -> tuple[Response, HumanAgent]: + if agent is None: + agent = sample_human_agent() + url = reverse("human-agent-detail", kwargs={"pk": agent.id}) + response = self.client.delete(url, format="json", **kwargs) + agent.refresh_from_db() + return response, agent + + +class TestHumanAgentViewSetUnauthenticated(TestHumanAgentViewSet): + def test_create_unauthenticated(self): + response, _ = self.create() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_retrieve_unauthenticated(self): + response = self.retrieve() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_list_unauthenticated(self): + response = self.list() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_update_unauthenticated(self): + response, _, _ = self.update() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_delete_unauthenticated(self): + response, _ = self.delete() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class TestHumanAgentViewSetAuthenticated(TestHumanAgentViewSet): + def setUp(self) -> None: + self.user = sample_human_agent() + configure_api_client(self.client, user=self.user) + + def test_create_authenticated(self): + response, payload = self.create() + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn("id", response.data) + self.assertEqual(response.data["name"], payload["name"]) + self.assertEqual(response.data["email"], payload["email"]) + + def test_retrieve_authenticated(self): + response = self.retrieve() + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_list_authenticated(self): + response = self.list() + self.assertEqual(response.status_code, status.HTTP_200_OK) + # 3 created + 1 authenticated user in setUp + self.assertEqual(len(response.data["results"]), 4) + + def test_update_self_authenticated(self): + response, agent, payload = self.update(agent=self.user) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(agent.name, payload["name"]) + self.assertEqual(agent.email, payload["email"]) + + def test_update_other_returns_404(self): + other = sample_human_agent() + response, _, _ = self.update(agent=other) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_self_authenticated(self): + response, agent = self.delete(agent=self.user) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertTrue(agent.is_removed) + + def test_delete_other_returns_404(self): + other = sample_human_agent() + response, _ = self.delete(agent=other) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/src/accounts/tests/test_register_view.py b/src/accounts/tests/test_register_view.py new file mode 100644 index 0000000..e6b5893 --- /dev/null +++ b/src/accounts/tests/test_register_view.py @@ -0,0 +1,33 @@ +from rest_framework.test import APITestCase +from django.urls import reverse +from rest_framework import status + +from accounts.models import HumanAgent +from config.faker import fake + + +class TestRegisterHumanAgentView(APITestCase): + def url(self) -> str: + return reverse("register-human-agent") + + def payload(self, **overrides) -> dict: + data = { + "name": fake.name(), + "email": fake.ascii_email(), + "password": "password-strong", + } + data.update(overrides) + return data + + def test_register_success(self): + response = self.client.post(self.url(), self.payload(), format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn("id", response.data) + self.assertIn("email", response.data) + self.assertTrue(HumanAgent.objects.filter(id=response.data["id"]).exists()) + + def test_register_missing_password(self): + data = self.payload() + data.pop("password") + response = self.client.post(self.url(), data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/src/accounts/typing.py b/src/accounts/typing.py new file mode 100644 index 0000000..8155fc1 --- /dev/null +++ b/src/accounts/typing.py @@ -0,0 +1,17 @@ +from typing import TypedDict, TypeVar +from django.contrib.auth.models import User + +AuthModel = TypeVar("AuthModel", bound=User) + + +class UserDTO(TypedDict, total=False): + email: str + name: str + is_active: bool + is_superuser: bool + is_staff: bool + password: str + + +class HumanAgentDTO(UserDTO, total=False): + pass diff --git a/src/accounts/urls.py b/src/accounts/urls.py index 048c9ae..53bb74f 100644 --- a/src/accounts/urls.py +++ b/src/accounts/urls.py @@ -5,8 +5,6 @@ from .views import ContactViewSet, HumanAgentViewSet, RegisterHumanAgentView -app_name = "accounts" - router = DefaultRouter() router.register("human-agents", HumanAgentViewSet, basename="human-agent") diff --git a/src/accounts/views.py b/src/accounts/views.py index 1494972..aba23df 100644 --- a/src/accounts/views.py +++ b/src/accounts/views.py @@ -1,5 +1,6 @@ from __future__ import annotations +from rest_framework.filters import OrderingFilter from rest_framework import status from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response @@ -76,8 +77,7 @@ class ContactViewSet(GetCacheMixin, ReadOnlyModelViewSet): queryset = Contact.objects.select_related("channel").all() serializer_class = ContactSerializer permission_classes = [IsAuthenticated] - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, OrderingFilter] filterset_fields = ["channel"] ordering = ["-created"] ordering_fields = ["created", "modified"] - search_fields = ["name", "channel_identifier"] diff --git a/src/assets/helpers.py b/src/assets/helpers.py new file mode 100644 index 0000000..3394a52 --- /dev/null +++ b/src/assets/helpers.py @@ -0,0 +1,35 @@ +import base64 + +from rest_framework.test import APIClient as _APIClient +from rest_framework_simplejwt.serializers import ( + TokenObtainPairSerializer as _TokenObtainPairSerializer, +) + + +def configure_api_client( # noqa + client: _APIClient, + set_header_version=True, + basic_auth=False, + access_token=None, + user=None, + password=None, +): + headers = {} + if set_header_version: + headers["HTTP_ACCEPT"] = "application/json; version=v1_web" + + if access_token is not None and not basic_auth: + headers["HTTP_AUTHORIZATION"] = f"Bearer {access_token}" + + if user is not None and not basic_auth: + access = str(_TokenObtainPairSerializer.get_token(user).access_token) + headers["HTTP_AUTHORIZATION"] = f"Bearer {access}" + client.force_authenticate(user=user) + + if basic_auth and password: + access = base64.b64encode(str.encode(f"{user.email}:{password}")).decode( + "iso-8859-1", + ) + headers["HTTP_AUTHORIZATION"] = f"Basic {access}" + + client.credentials(**headers) diff --git a/src/channels/migrations/0002_alter_channel_name.py b/src/channels/migrations/0002_alter_channel_name.py new file mode 100644 index 0000000..b9b62d6 --- /dev/null +++ b/src/channels/migrations/0002_alter_channel_name.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.7 on 2025-10-09 21:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("channels", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="channel", + name="name", + field=models.CharField( + choices=[ + ("whatsapp", "WhatsApp"), + ("telegram", "Telegram"), + ("mock", "Mock Channel"), + ], + max_length=20, + ), + ), + ] diff --git a/src/channels/models.py b/src/channels/models.py index 3854148..226fe89 100644 --- a/src/channels/models.py +++ b/src/channels/models.py @@ -8,7 +8,7 @@ class ChannelType(models.TextChoices): TELEGRAM = "telegram", "Telegram" MOCK = "mock", "Mock Channel" - name = models.CharField(max_length=20, choices=ChannelType.choices, unique=True) + name = models.CharField(max_length=20, choices=ChannelType.choices) config = models.JSONField( default=dict, help_text="Configurações específicas do canal em formato JSON" ) diff --git a/src/channels/serializers.py b/src/channels/serializers.py index 47155a6..6b83c6d 100644 --- a/src/channels/serializers.py +++ b/src/channels/serializers.py @@ -21,7 +21,7 @@ def validate(self, data): if getattr(self, "instance", None) is not None: qs = qs.exclude(pk=self.instance.pk) - duplicate_exists = qs.filter(_config__token=token).exists() + duplicate_exists = qs.filter(config__token=token).exists() if duplicate_exists: raise serializers.ValidationError( diff --git a/src/channels/tests.py b/src/channels/tests.py deleted file mode 100644 index a39b155..0000000 --- a/src/channels/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/src/channels/tests/helpers.py b/src/channels/tests/helpers.py index f10a738..7e73c05 100644 --- a/src/channels/tests/helpers.py +++ b/src/channels/tests/helpers.py @@ -8,7 +8,7 @@ def sample_channel( - prepare=False, + prepare: bool = False, **kwargs: Unpack[ChannelsDTO], ) -> Channel | list[Channel]: kwargs.setdefault("name", fake.random_element(Channel.ChannelType.values)) @@ -17,5 +17,4 @@ def sample_channel( if prepare: return baker.prepare(Channel, **kwargs) - return baker.make(Channel, **kwargs) diff --git a/src/channels/tests/test_channel_view_set.py b/src/channels/tests/test_channel_view_set.py index e69de29..d20a310 100644 --- a/src/channels/tests/test_channel_view_set.py +++ b/src/channels/tests/test_channel_view_set.py @@ -0,0 +1,191 @@ +from accounts.tests.helpers import sample_human_agent +from django.core.cache import cache +from assets.helpers import configure_api_client + +from rest_framework.test import APITestCase +from django.urls import reverse +from channels.models import Channel +from rest_framework import status +from rest_framework.response import Response +from .helpers import sample_channel +from config.faker import fake + + +class TestChannelViewSet(APITestCase): + def create(self, payload: dict = None, **args) -> tuple[Response, dict]: + url = reverse("channel-list") + + if payload is None: + payload = { + "name": Channel.ChannelType.TELEGRAM, + "config": {"token": fake.uuid4()}, + } + + response = self.client.post(url, payload, format="json", **args) + return response, payload + + def retrieve(self, channel_id: int = None, **kwargs) -> Response: + if channel_id is None: + channel = sample_channel() + channel_id = channel.id + url = reverse("channel-detail", kwargs={"pk": channel_id}) + + return self.client.get(url, format="json", **kwargs) + + def list(self, filters=None, **kwargs) -> Response: + sample_channel(_quantity=3) + url = reverse("channel-list") + + return self.client.get(url, filters) + + def update( + self, channel: Channel = None, payload: dict = None, **kwargs + ) -> tuple[Response, Channel, dict]: + if channel is None: + channel = sample_channel() + url = reverse("channel-detail", kwargs={"pk": channel.id}) + + if payload is None: + payload = { + "name": Channel.ChannelType.TELEGRAM, + "config": {"token": fake.uuid4()}, + } + + response = self.client.put(url, payload, format="json", **kwargs) + + channel.refresh_from_db() + + return response, channel, payload + + def delete(self, channel: Channel = None, **kwargs) -> tuple[Response, Channel]: + if channel is None: + channel = sample_channel() + url = reverse("channel-detail", kwargs={"pk": channel.id}) + + response = self.client.delete(url, format="json", **kwargs) + + channel.refresh_from_db() + + return response, channel + + +class TestChannelViewSetUnauthenticated(TestChannelViewSet): + def test_create_unauthenticated(self): + response, _ = self.create() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_retrieve_unauthenticated(self): + response = self.retrieve() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_list_unauthenticated(self): + response = self.list() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_update_unauthenticated(self): + response, _, _ = self.update() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_delete_unauthenticated(self): + response, _ = self.delete() + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class TestChannelViewSetAuthenticated(TestChannelViewSet): + def setUp(self) -> None: + self.user = sample_human_agent() + configure_api_client(self.client, user=self.user) + + def test_create_authenticated(self): + response, payload = self.create() + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn("id", response.data) + self.assertEqual(response.data["name"], payload["name"]) + self.assertEqual(response.data["config"], payload["config"]) + self.assertTrue(response.data["is_active"]) + + def test_retrieve_authenticated(self): + response = self.retrieve() + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_list_authenticated(self): + response = self.list() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 3) + + def test_update_authenticated(self): + response, channel, payload = self.update() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(channel.name, payload["name"]) + self.assertEqual(channel.config, payload["config"]) + self.assertTrue(channel.is_active) + + def test_delete_authenticated(self): + response, channel = self.delete() + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertTrue(channel.is_removed) + + def test_cache_set_after_list(self): + cache.clear() + Channel.objects.all().delete() + # Use helper to perform initial list (seeds 3 and caches result) + response = self.list() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 3) + # Create directly in DB (no view invalidation) + Channel.objects.create( + name=fake.uuid4(), config={"token": fake.uuid4()}, is_active=True + ) + # Cached response should still reflect the cached 3 results + url = reverse("channel-list") + response_cached = self.client.get(url) + self.assertEqual(response_cached.status_code, status.HTTP_200_OK) + self.assertEqual(len(response_cached.data["results"]), 3) + + def test_cache_invalidated_on_create(self): + cache.clear() + Channel.objects.all().delete() + # Cache list using helper (seeds 3, caches response) + first_list = self.list() + self.assertEqual(first_list.status_code, status.HTTP_200_OK) + self.assertEqual(len(first_list.data["results"]), 3) + # Create via API using helper (triggers invalidation) + resp_create, _ = self.create() + self.assertEqual(resp_create.status_code, status.HTTP_201_CREATED) + # List again (direct GET to avoid reseeding) should now include the new item + url = reverse("channel-list") + response_after = self.client.get(url) + self.assertEqual(response_after.status_code, status.HTTP_200_OK) + self.assertEqual(len(response_after.data["results"]), 4) + + def test_cache_invalidated_on_update(self): + cache.clear() + # Create channel and cache list using helper + channel = sample_channel() + self.list() + # Update via API using helper + new_name = Channel.ChannelType.TELEGRAM + update_payload = {"name": new_name, "config": {"token": fake.uuid4()}} + resp_upd, _, _ = self.update(channel=channel, payload=update_payload) + self.assertEqual(resp_upd.status_code, status.HTTP_200_OK) + # List again (direct GET) should contain updated name + url = reverse("channel-list") + resp2 = self.client.get(url) + self.assertEqual(resp2.status_code, status.HTTP_200_OK) + names = [item["name"] for item in resp2.data["results"]] + self.assertIn(new_name, names) + + def test_cache_invalidated_on_delete(self): + cache.clear() + # Create and cache list using helpers + channel = sample_channel() + self.list() + # Delete via API using helper + resp_del, _ = self.delete(channel=channel) + self.assertEqual(resp_del.status_code, status.HTTP_204_NO_CONTENT) + # List again (direct GET) should not include the deleted channel + url = reverse("channel-list") + resp2 = self.client.get(url) + self.assertEqual(resp2.status_code, status.HTTP_200_OK) + ids = [item["id"] for item in resp2.data["results"]] + self.assertNotIn(channel.id, ids) diff --git a/src/channels/tests/test_mock_webhook_view.py b/src/channels/tests/test_mock_webhook_view.py new file mode 100644 index 0000000..d31d410 --- /dev/null +++ b/src/channels/tests/test_mock_webhook_view.py @@ -0,0 +1,47 @@ +from accounts.tests.helpers import sample_human_agent +from assets.helpers import configure_api_client + +from rest_framework.test import APITestCase +from django.urls import reverse +from channels.models import Channel +from rest_framework import status +from config.faker import fake + + +class _BaseMockWebhookView(APITestCase): + def setUp(self) -> None: + self.channel = Channel.objects.create( + name=Channel.ChannelType.MOCK, + config={"token": fake.uuid4()}, + is_active=True, + ) + + def url(self) -> str: + return reverse("mock-webhook") + + def payload(self, text: str = None, channel_identifier: str | None = None) -> dict: + return { + "channel_identifier": channel_identifier or str(fake.random_int()), + "content": text or fake.sentence(), + } + + +class TestMockWebhookViewUnauthenticated(_BaseMockWebhookView): + def test_requires_authentication(self): + response = self.client.post(self.url(), self.payload(), format="json") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class TestMockWebhookViewAuthenticated(_BaseMockWebhookView): + def setUp(self) -> None: + super().setUp() + self.user = sample_human_agent() + configure_api_client(self.client, user=self.user) + + def test_ignores_invalid_payload(self): + response = self.client.post(self.url(), {}, format="json") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_persists_inbound_message(self): + response = self.client.post(self.url(), self.payload(), format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/src/channels/tests/test_send_message_view.py b/src/channels/tests/test_send_message_view.py new file mode 100644 index 0000000..fbabff9 --- /dev/null +++ b/src/channels/tests/test_send_message_view.py @@ -0,0 +1,46 @@ +from accounts.tests.helpers import sample_human_agent +from assets.helpers import configure_api_client + +from rest_framework.test import APITestCase +from django.urls import reverse +from channels.models import Channel +from rest_framework import status +from config.faker import fake +from accounts.models import Contact + + +class _BaseSendMessageView(APITestCase): + def setUp(self) -> None: + self.channel = Channel.objects.create( + name=Channel.ChannelType.MOCK, + config={"token": fake.uuid4()}, + is_active=True, + ) + self.contact = Contact.objects.create( + name="Test Contact", + channel=self.channel, + channel_identifier=str(fake.random_int()), + ) + + def url(self) -> str: + return reverse("send-message") + + +class TestSendMessageViewUnauthenticated(_BaseSendMessageView): + def test_requires_authentication(self): + response = self.client.post(self.url(), {}, format="json") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class TestSendMessageViewAuthenticated(_BaseSendMessageView): + def setUp(self) -> None: + super().setUp() + self.user = sample_human_agent() + configure_api_client(self.client, user=self.user) + + def test_sends_message_successfully(self): + payload = {"contact_id": self.contact.id, "content": "Hello!"} + response = self.client.post(self.url(), payload, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("ok", response.data) + self.assertTrue(response.data["ok"]) diff --git a/src/channels/tests/test_telegram_webhook_view.py b/src/channels/tests/test_telegram_webhook_view.py new file mode 100644 index 0000000..57f1bd9 --- /dev/null +++ b/src/channels/tests/test_telegram_webhook_view.py @@ -0,0 +1,51 @@ +from accounts.tests.helpers import sample_human_agent +from assets.helpers import configure_api_client + +from rest_framework.test import APITestCase +from django.urls import reverse +from channels.models import Channel +from rest_framework import status +from config.faker import fake + + +class _BaseTelegramWebhookView(APITestCase): + def setUp(self) -> None: + self.channel = Channel.objects.create( + name=Channel.ChannelType.TELEGRAM, + config={"token": fake.uuid4()}, + is_active=True, + ) + + def url(self) -> str: + return reverse("telegram-webhook") + + def payload(self, text: str = None, chat_id: int | None = None) -> dict: + return { + "update_id": fake.random_int(), + "message": { + "message_id": fake.random_int(), + "chat": {"id": chat_id or fake.random_int()}, + "text": text or fake.sentence(), + }, + } + + +class TestTelegramWebhookViewUnauthenticated(_BaseTelegramWebhookView): + def test_requires_authentication(self): + response = self.client.post(self.url(), self.payload(), format="json") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class TestTelegramWebhookViewAuthenticated(_BaseTelegramWebhookView): + def setUp(self) -> None: + super().setUp() + self.user = sample_human_agent() + configure_api_client(self.client, user=self.user) + + def test_ignores_non_message_updates(self): + response = self.client.post(self.url(), {"callback_query": {}}, format="json") + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_persists_inbound_message(self): + response = self.client.post(self.url(), self.payload(), format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/src/channels/urls.py b/src/channels/urls.py index 5222ae2..e16e80d 100644 --- a/src/channels/urls.py +++ b/src/channels/urls.py @@ -8,11 +8,9 @@ TelegramWebhookView, ) -app_name = "channels" - router = routers.DefaultRouter() -router.register("channels", viewset=ChannelsViewSet, basename="channels") +router.register("channels", viewset=ChannelsViewSet, basename="channel") urlpatterns = [ path("telegram-webhook/", TelegramWebhookView.as_view(), name="telegram-webhook"), diff --git a/src/config/settings.py b/src/config/settings.py index 0fe4f32..2c17e2c 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -49,6 +49,7 @@ "assets", # libs "rest_framework", + "django_filters", "rest_framework_simplejwt", "model_utils", "polymorphic", diff --git a/src/config/urls.py b/src/config/urls.py index 6c43574..a1a81f9 100644 --- a/src/config/urls.py +++ b/src/config/urls.py @@ -31,6 +31,7 @@ path("admin/", admin.site.urls), path("api/channels/", include("channels.urls")), path("api/accounts/", include("accounts.urls")), + path("api/messaging/", include("messaging.urls")), path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("api/schema/", SpectacularAPIView.as_view(), name="schema"), diff --git a/src/messaging/tests.py b/src/messaging/tests.py deleted file mode 100644 index a39b155..0000000 --- a/src/messaging/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/src/messaging/tests/__init__.py b/src/messaging/tests/__init__.py new file mode 100644 index 0000000..b2670d4 --- /dev/null +++ b/src/messaging/tests/__init__.py @@ -0,0 +1 @@ +# Ensures messaging.tests is a package for test discovery diff --git a/src/messaging/tests/test_message_view_set.py b/src/messaging/tests/test_message_view_set.py new file mode 100644 index 0000000..578d4f9 --- /dev/null +++ b/src/messaging/tests/test_message_view_set.py @@ -0,0 +1,181 @@ +from assets.helpers import configure_api_client +from django.core.cache import cache + +from rest_framework.test import APITestCase +from django.urls import reverse +from rest_framework import status + +from accounts.tests.helpers import sample_human_agent +from accounts.models import Contact +from channels.tests.helpers import sample_channel +from channels.models import Channel +from messaging.models import Message +from config.faker import fake + + +class TestMessageViewSet(APITestCase): + def setUp(self) -> None: + self.user = sample_human_agent() + configure_api_client(self.client, user=self.user) + self.channel = sample_channel() + self.contact_a = Contact.objects.create( + name=fake.name(), + channel=self.channel, + channel_identifier=str(fake.random_int()), + ) + self.contact_b = Contact.objects.create( + name=fake.name(), + channel=self.channel, + channel_identifier=str(fake.random_int()), + ) + + Message.objects.create( + contact=self.contact_a, + content="hello alpha", + direction=Message.MessageDirection.INBOUND, + ) + Message.objects.create( + contact=self.contact_a, + content="bravo world", + direction=Message.MessageDirection.OUTBOUND, + ) + Message.objects.create( + contact=self.contact_b, + content="charlie search", + direction=Message.MessageDirection.INBOUND, + ) + + def list(self, params=None): + url = reverse("message-list") + return self.client.get(url, params) + + def retrieve(self, pk: int): + url = reverse("message-detail", kwargs={"pk": pk}) + return self.client.get(url) + + def test_retrieve_authenticated(self): + any_id = Message.objects.first().id + response = self.retrieve(any_id) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_filter_by_contact(self): + response = self.list({"contact": self.contact_a.id}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for item in response.data["results"]: + self.assertEqual(item["contact"], self.contact_a.id) + + def test_filter_by_agent(self): + response = self.list({"agent": self.user.id}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["results"]), 0) + + def test_ordering_created(self): + response = self.list({"ordering": "created"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = response.data["results"] + self.assertLessEqual(results[0]["created"], results[-1]["created"]) + + def test_ordering_modified_desc(self): + response = self.list({"ordering": "-modified"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + results = response.data["results"] + self.assertGreaterEqual(results[0]["modified"], results[-1]["modified"]) + + def test_cache_invalidated_after_send_message(self): + cache.clear() + # Initial list caches current messages + first = self.list() + self.assertEqual(first.status_code, status.HTTP_200_OK) + initial_count = len(first.data["results"]) + + # Create a contact under a MOCK channel and send message via API + mock_channel = Channel.objects.create( + name=Channel.ChannelType.MOCK, + config={"token": fake.uuid4()}, + is_active=True, + ) + contact = Contact.objects.create( + name=fake.name(), + channel=mock_channel, + channel_identifier=str(fake.random_int()), + ) + send_url = reverse("send-message") + resp = self.client.post( + send_url, + {"contact_id": contact.id, "content": "hello from test"}, + format="json", + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + # List again should reflect the new message (cache invalidated by view) + after = self.list() + self.assertEqual(after.status_code, status.HTTP_200_OK) + self.assertEqual(len(after.data["results"]), initial_count + 1) + + def test_cache_invalidated_after_mock_webhook(self): + cache.clear() + first = self.list() + self.assertEqual(first.status_code, status.HTTP_200_OK) + initial_count = len(first.data["results"]) + + # Ensure a single active MOCK channel + Channel.objects.filter(name=Channel.ChannelType.MOCK).update(is_active=False) + Channel.objects.create( + name=Channel.ChannelType.MOCK, + config={"token": fake.uuid4()}, + is_active=True, + ) + webhook_url = reverse("mock-webhook") + payload = { + "channel_identifier": str(fake.random_int()), + "content": "inbound mock", + } + resp = self.client.post(webhook_url, payload, format="json") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + after = self.list() + self.assertEqual(after.status_code, status.HTTP_200_OK) + self.assertEqual(len(after.data["results"]), initial_count + 1) + + def test_cache_invalidated_after_telegram_webhook(self): + cache.clear() + first = self.list() + self.assertEqual(first.status_code, status.HTTP_200_OK) + initial_count = len(first.data["results"]) + + # Ensure a single active TELEGRAM channel + Channel.objects.filter(name=Channel.ChannelType.TELEGRAM).update( + is_active=False + ) + Channel.objects.create( + name=Channel.ChannelType.TELEGRAM, + config={"token": fake.uuid4()}, + is_active=True, + ) + webhook_url = reverse("telegram-webhook") + payload = { + "update_id": fake.random_int(), + "message": { + "message_id": fake.random_int(), + "chat": {"id": fake.random_int()}, + "text": "inbound telegram", + }, + } + resp = self.client.post(webhook_url, payload, format="json") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + + after = self.list() + self.assertEqual(after.status_code, status.HTTP_200_OK) + self.assertEqual(len(after.data["results"]), initial_count + 1) + + +class TestMessageViewSetUnauthenticated(APITestCase): + def test_list_unauthenticated(self): + url = reverse("message-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_retrieve_unauthenticated(self): + url = reverse("message-detail", kwargs={"pk": 1}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/src/messaging/urls.py b/src/messaging/urls.py index a190cb1..622e4be 100644 --- a/src/messaging/urls.py +++ b/src/messaging/urls.py @@ -5,8 +5,6 @@ from .views import MessageViewSet -app_name = "messaging" - router = DefaultRouter() router.register("messages", MessageViewSet, basename="message") diff --git a/src/messaging/views.py b/src/messaging/views.py index 97eda08..3540447 100644 --- a/src/messaging/views.py +++ b/src/messaging/views.py @@ -30,4 +30,3 @@ class MessageViewSet(GetCacheMixin, ReadOnlyModelViewSet): filterset_fields = ["contact", "agent"] ordering = ["-created"] ordering_fields = ["created", "modified"] - search_fields = ["content"]