complete mattermost integration
This commit is contained in:
parent
b7239defcf
commit
dcb1873608
24 changed files with 509 additions and 40 deletions
41
.env.example
Normal file
41
.env.example
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Database
|
||||||
|
DB_NAME=gooyal_chat
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PASSWORD=
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
DEBUG=true
|
||||||
|
|
||||||
|
# OAuth2 / SSO provider
|
||||||
|
OAUTH2_PROVIDER_BASE_PUBLIC_URL=https://accounts.example.com/oauth2
|
||||||
|
OAUTH2_PROVIDER_BASE_PRIVATE_URL=https://accounts.example.com/oauth2
|
||||||
|
OAUTH2_PROVIDER_CLIENT_ID=
|
||||||
|
OAUTH2_PROVIDER_CLIENT_SECRET=
|
||||||
|
OAUTH2_PROVIDER_SCOPES=
|
||||||
|
|
||||||
|
# Redis (used for cache, channel layer, and long-poll)
|
||||||
|
REDIS_BASE_URL=redis://127.0.0.1:6379/1
|
||||||
|
|
||||||
|
# Mattermost
|
||||||
|
# MATTERMOST_TOKEN must be a Personal Access Token (not a session token — session tokens expire)
|
||||||
|
# Enable at: System Console → Integrations → Integration Management → Enable Personal Access Tokens
|
||||||
|
MATTERMOST_URL=https://chat.example.com
|
||||||
|
MATTERMOST_TOKEN=
|
||||||
|
MATTERMOST_TEAM_ID=
|
||||||
|
MATTERMOST_SERVICE_USERNAME=chat-service
|
||||||
|
MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid
|
||||||
|
|
||||||
|
# MinIO object storage
|
||||||
|
MINIO_ENDPOINT=localhost:9000
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_BUCKET_CHAT=chat
|
||||||
|
|
||||||
|
# Chat long-poll tuning
|
||||||
|
CHAT_LONG_POLL_TIMEOUT_SECONDS=25
|
||||||
|
CHAT_LONG_POLL_INTERVAL_SECONDS=1
|
||||||
|
|
||||||
|
# Native library paths (macOS Homebrew — leave empty on Linux)
|
||||||
|
GDAL_LIBRARY_PATH=
|
||||||
|
GEOS_LIBRARY_PATH=
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,3 +5,5 @@ media
|
||||||
/delme.py
|
/delme.py
|
||||||
/log/accounts.log
|
/log/accounts.log
|
||||||
/log/errors.log
|
/log/errors.log
|
||||||
|
__pycache__
|
||||||
|
.claude
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,5 +1,6 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
@ -8,6 +9,7 @@ from apps.chat.models import ConversationParticipant
|
||||||
from apps.chat.serializers.conversations import (
|
from apps.chat.serializers.conversations import (
|
||||||
ConversationListSerializer,
|
ConversationListSerializer,
|
||||||
ConversationSerializer,
|
ConversationSerializer,
|
||||||
|
CreateConversationSerializer,
|
||||||
)
|
)
|
||||||
from apps.chat.services.conversation import ConversationService
|
from apps.chat.services.conversation import ConversationService
|
||||||
from apps.chat.services.message import MessageService
|
from apps.chat.services.message import MessageService
|
||||||
|
|
@ -15,11 +17,21 @@ from apps.chat.services.read_state import ReadStateService
|
||||||
|
|
||||||
|
|
||||||
class ConversationCreateView(APIView):
|
class ConversationCreateView(APIView):
|
||||||
def post(self, request):
|
authentication_classes = []
|
||||||
user_1_id = UUID(request.data["user_1_id"])
|
permission_classes = []
|
||||||
user_2_id = UUID(request.data["user_2_id"])
|
|
||||||
|
|
||||||
conversation = ConversationService().create(user_1_id, user_2_id)
|
@extend_schema(
|
||||||
|
request=CreateConversationSerializer,
|
||||||
|
responses={201: ConversationSerializer},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = CreateConversationSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
conversation = ConversationService().create(
|
||||||
|
serializer.validated_data["user_1_id"],
|
||||||
|
serializer.validated_data["user_2_id"],
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
ConversationSerializer(conversation).data,
|
ConversationSerializer(conversation).data,
|
||||||
|
|
@ -28,6 +40,16 @@ class ConversationCreateView(APIView):
|
||||||
|
|
||||||
|
|
||||||
class UserConversationListView(APIView):
|
class UserConversationListView(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = []
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter("page", int, description="0-based page number", default=0),
|
||||||
|
OpenApiParameter("page_size", int, description="Results per page", default=20),
|
||||||
|
],
|
||||||
|
responses={200: ConversationListSerializer(many=True)},
|
||||||
|
)
|
||||||
def get(self, request, user_id):
|
def get(self, request, user_id):
|
||||||
uid = UUID(str(user_id))
|
uid = UUID(str(user_id))
|
||||||
page = int(request.query_params.get("page", 0))
|
page = int(request.query_params.get("page", 0))
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,50 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from apps.chat.serializers.messages import MessageSerializer
|
from apps.chat.serializers.messages import MessageSerializer, SendMessageSerializer
|
||||||
from apps.chat.services.message import MessageService
|
from apps.chat.services.message import MessageService
|
||||||
|
|
||||||
|
|
||||||
class MessageView(APIView):
|
class MessageView(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = []
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=SendMessageSerializer,
|
||||||
|
responses={201: MessageSerializer},
|
||||||
|
)
|
||||||
def post(self, request, chat_id):
|
def post(self, request, chat_id):
|
||||||
sender_id = UUID(request.data["sender_id"])
|
serializer = SendMessageSerializer(data=request.data)
|
||||||
message_type = request.data.get("message_type", "text")
|
serializer.is_valid(raise_exception=True)
|
||||||
text = request.data.get("text")
|
data = serializer.validated_data
|
||||||
file = request.FILES.get("file")
|
|
||||||
|
|
||||||
result = MessageService().send(
|
result = MessageService().send(
|
||||||
conversation_id=UUID(str(chat_id)),
|
conversation_id=UUID(str(chat_id)),
|
||||||
sender_id=sender_id,
|
sender_id=data["sender_id"],
|
||||||
message_type=message_type,
|
message_type=data["message_type"],
|
||||||
text=text,
|
text=data.get("text"),
|
||||||
file=file,
|
file=data.get("file"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED)
|
return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter("page", int, description="0-based page number", default=0),
|
||||||
|
OpenApiParameter("per_page", int, description="Results per page", default=20),
|
||||||
|
OpenApiParameter(
|
||||||
|
"since",
|
||||||
|
int,
|
||||||
|
required=False,
|
||||||
|
description="Return only posts after this Unix timestamp (milliseconds)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses={200: MessageSerializer(many=True)},
|
||||||
|
)
|
||||||
def get(self, request, chat_id):
|
def get(self, request, chat_id):
|
||||||
page = int(request.query_params.get("page", 0))
|
page = int(request.query_params.get("page", 0))
|
||||||
per_page = int(request.query_params.get("per_page", 20))
|
per_page = int(request.query_params.get("per_page", 20))
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,57 @@ from uuid import UUID
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django_redis import get_redis_connection
|
from django_redis import get_redis_connection
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from apps.chat.serializers.read_state import ReadStateSerializer
|
from apps.chat.serializers.messages import MessageSerializer
|
||||||
|
from apps.chat.serializers.read_state import MarkReadSerializer, ReadStateSerializer
|
||||||
from apps.chat.services.read_state import ReadStateService
|
from apps.chat.services.read_state import ReadStateService
|
||||||
|
|
||||||
|
|
||||||
class ReadStateView(APIView):
|
class ReadStateView(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = []
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=MarkReadSerializer,
|
||||||
|
responses={200: ReadStateSerializer},
|
||||||
|
)
|
||||||
def post(self, request, chat_id):
|
def post(self, request, chat_id):
|
||||||
user_id = UUID(request.data["user_id"])
|
serializer = MarkReadSerializer(data=request.data)
|
||||||
post_id = request.data["post_id"]
|
serializer.is_valid(raise_exception=True)
|
||||||
|
data = serializer.validated_data
|
||||||
|
|
||||||
read_state = ReadStateService().mark_read(
|
read_state = ReadStateService().mark_read(
|
||||||
conversation_id=UUID(str(chat_id)),
|
conversation_id=UUID(str(chat_id)),
|
||||||
user_id=user_id,
|
user_id=data["user_id"],
|
||||||
post_id=post_id,
|
post_id=data["post_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
data = {
|
return Response(
|
||||||
|
ReadStateSerializer(
|
||||||
|
{
|
||||||
"conversation_id": read_state.conversation_id,
|
"conversation_id": read_state.conversation_id,
|
||||||
"user_id": read_state.user_id,
|
"user_id": read_state.user_id,
|
||||||
"has_unread": False,
|
"has_unread": False,
|
||||||
}
|
}
|
||||||
return Response(ReadStateSerializer(data).data)
|
).data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatEventsView(APIView):
|
class ChatEventsView(APIView):
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = []
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
responses={200: MessageSerializer(many=True)},
|
||||||
|
description=(
|
||||||
|
"Long-poll endpoint. Blocks up to CHAT_LONG_POLL_TIMEOUT_SECONDS "
|
||||||
|
"waiting for a new message event. Returns immediately when an event "
|
||||||
|
"arrives; returns an empty list on timeout."
|
||||||
|
),
|
||||||
|
)
|
||||||
def get(self, request, chat_id):
|
def get(self, request, chat_id):
|
||||||
timeout = getattr(settings, "CHAT_LONG_POLL_TIMEOUT_SECONDS", 25)
|
timeout = getattr(settings, "CHAT_LONG_POLL_TIMEOUT_SECONDS", 25)
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,8 +1,14 @@
|
||||||
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from apps.chat.models import Conversation, ConversationParticipant
|
from apps.chat.models import Conversation, ConversationParticipant
|
||||||
|
|
||||||
|
|
||||||
|
class CreateConversationSerializer(serializers.Serializer):
|
||||||
|
user_1_id = serializers.UUIDField()
|
||||||
|
user_2_id = serializers.UUIDField()
|
||||||
|
|
||||||
|
|
||||||
class ConversationSerializer(serializers.ModelSerializer):
|
class ConversationSerializer(serializers.ModelSerializer):
|
||||||
participants = serializers.SerializerMethodField()
|
participants = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
|
@ -10,6 +16,7 @@ class ConversationSerializer(serializers.ModelSerializer):
|
||||||
model = Conversation
|
model = Conversation
|
||||||
fields = ["id", "type", "created_at", "participants"]
|
fields = ["id", "type", "created_at", "participants"]
|
||||||
|
|
||||||
|
@extend_schema_field(serializers.ListField(child=serializers.UUIDField()))
|
||||||
def get_participants(self, obj):
|
def get_participants(self, obj):
|
||||||
return list(
|
return list(
|
||||||
ConversationParticipant.objects.filter(conversation_id=obj.id)
|
ConversationParticipant.objects.filter(conversation_id=obj.id)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class SendMessageSerializer(serializers.Serializer):
|
||||||
|
sender_id = serializers.UUIDField()
|
||||||
|
message_type = serializers.ChoiceField(choices=["text", "image", "file"], default="text")
|
||||||
|
text = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
file = serializers.FileField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
class MessageSerializer(serializers.Serializer):
|
class MessageSerializer(serializers.Serializer):
|
||||||
post_id = serializers.CharField()
|
post_id = serializers.CharField()
|
||||||
sender_id = serializers.UUIDField(allow_null=True)
|
sender_id = serializers.UUIDField(allow_null=True)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class MarkReadSerializer(serializers.Serializer):
|
||||||
|
user_id = serializers.UUIDField()
|
||||||
|
post_id = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class ReadStateSerializer(serializers.Serializer):
|
class ReadStateSerializer(serializers.Serializer):
|
||||||
conversation_id = serializers.UUIDField()
|
conversation_id = serializers.UUIDField()
|
||||||
user_id = serializers.UUIDField()
|
user_id = serializers.UUIDField()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from apps.chat.models import (
|
||||||
|
Conversation,
|
||||||
|
ConversationParticipant,
|
||||||
|
MattermostAccountMapping,
|
||||||
|
)
|
||||||
|
from apps.chat.services.conversation import ConversationService
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mm_client(mm_user_ids=("mm-u1", "mm-u2"), channel_id="mm-ch-1"):
|
||||||
|
client = Mock()
|
||||||
|
client.get_or_create_user.side_effect = list(mm_user_ids)
|
||||||
|
client.create_private_channel.return_value = channel_id
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_conversation_success():
|
||||||
|
user_1 = uuid.uuid4()
|
||||||
|
user_2 = uuid.uuid4()
|
||||||
|
|
||||||
|
svc = ConversationService(mattermost_client=_make_mm_client())
|
||||||
|
conv = svc.create(user_1, user_2)
|
||||||
|
|
||||||
|
assert Conversation.objects.filter(id=conv.id).exists()
|
||||||
|
|
||||||
|
participants = ConversationParticipant.objects.filter(conversation=conv)
|
||||||
|
assert participants.count() == 2
|
||||||
|
participant_ids = set(participants.values_list("user_id", flat=True))
|
||||||
|
assert user_1 in participant_ids
|
||||||
|
assert user_2 in participant_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_conversation_creates_mattermost_mapping():
|
||||||
|
user_1 = uuid.uuid4()
|
||||||
|
user_2 = uuid.uuid4()
|
||||||
|
|
||||||
|
mm_client = _make_mm_client(mm_user_ids=("mm-user-aaa", "mm-user-bbb"))
|
||||||
|
svc = ConversationService(mattermost_client=mm_client)
|
||||||
|
svc.create(user_1, user_2)
|
||||||
|
|
||||||
|
mapping_1 = MattermostAccountMapping.objects.get(user_id=user_1)
|
||||||
|
mapping_2 = MattermostAccountMapping.objects.get(user_id=user_2)
|
||||||
|
|
||||||
|
assert mapping_1.mattermost_user_id == "mm-user-aaa"
|
||||||
|
assert mapping_2.mattermost_user_id == "mm-user-bbb"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_list_for_user_returns_only_user_conversations():
|
||||||
|
target_user = uuid.uuid4()
|
||||||
|
other_user = uuid.uuid4()
|
||||||
|
|
||||||
|
conv_with_user = Conversation.objects.create(mattermost_channel_id="ch-target")
|
||||||
|
ConversationParticipant.objects.create(
|
||||||
|
conversation=conv_with_user, user_id=target_user
|
||||||
|
)
|
||||||
|
|
||||||
|
conv_without_user = Conversation.objects.create(mattermost_channel_id="ch-other")
|
||||||
|
ConversationParticipant.objects.create(
|
||||||
|
conversation=conv_without_user, user_id=other_user
|
||||||
|
)
|
||||||
|
|
||||||
|
svc = ConversationService(mattermost_client=Mock())
|
||||||
|
result = svc.list_for_user(target_user, page=0, page_size=20)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].id == conv_with_user.id
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from apps.chat.models import Conversation, MattermostAccountMapping
|
||||||
|
from apps.chat.services.message import MessageService
|
||||||
|
|
||||||
|
|
||||||
|
def _make_service(*, mm_post_id="post-1", storage_url=None, publishers=None):
|
||||||
|
"""Return a MessageService with all external deps mocked."""
|
||||||
|
mm_client = Mock()
|
||||||
|
mm_client.post_message.return_value = mm_post_id
|
||||||
|
|
||||||
|
storage = Mock()
|
||||||
|
if storage_url:
|
||||||
|
storage.upload_file.return_value = storage_url
|
||||||
|
|
||||||
|
return (
|
||||||
|
MessageService(
|
||||||
|
mattermost_client=mm_client,
|
||||||
|
storage_service=storage,
|
||||||
|
publishers=publishers if publishers is not None else [],
|
||||||
|
),
|
||||||
|
mm_client,
|
||||||
|
storage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_send_text_message():
|
||||||
|
conv = Conversation.objects.create(mattermost_channel_id="ch-send-text")
|
||||||
|
sender_id = uuid.uuid4()
|
||||||
|
|
||||||
|
ws_publisher = Mock()
|
||||||
|
svc, mm_client, _ = _make_service(mm_post_id="post-abc", publishers=[ws_publisher])
|
||||||
|
|
||||||
|
result = svc.send(conv.id, sender_id, "text", text="Hello world")
|
||||||
|
|
||||||
|
mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world")
|
||||||
|
ws_publisher.publish.assert_called_once()
|
||||||
|
|
||||||
|
event = ws_publisher.publish.call_args[0][0]
|
||||||
|
assert event.post_id == "post-abc"
|
||||||
|
assert event.sender_id == sender_id
|
||||||
|
assert event.message_type == "text"
|
||||||
|
|
||||||
|
assert result["post_id"] == "post-abc"
|
||||||
|
assert result["text"] == "Hello world"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_send_image_message():
|
||||||
|
conv = Conversation.objects.create(mattermost_channel_id="ch-send-image")
|
||||||
|
sender_id = uuid.uuid4()
|
||||||
|
|
||||||
|
file_url = "http://minio.local/chat/abc123/photo.jpg"
|
||||||
|
svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[])
|
||||||
|
|
||||||
|
fake_file = Mock()
|
||||||
|
fake_file.name = "photo.jpg"
|
||||||
|
|
||||||
|
result = svc.send(conv.id, sender_id, "image", file=fake_file)
|
||||||
|
|
||||||
|
storage.upload_file.assert_called_once_with(fake_file, "photo.jpg")
|
||||||
|
mm_client.post_message.assert_called_once_with("ch-send-image", file_url)
|
||||||
|
assert result["url"] == file_url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_list_messages_normalized():
|
||||||
|
conv = Conversation.objects.create(mattermost_channel_id="ch-list")
|
||||||
|
sender_uuid = uuid.uuid4()
|
||||||
|
mm_user_id = "mm-user-xyz"
|
||||||
|
|
||||||
|
MattermostAccountMapping.objects.create(
|
||||||
|
user_id=sender_uuid, mattermost_user_id=mm_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_posts = [
|
||||||
|
{
|
||||||
|
"id": "p1",
|
||||||
|
"user_id": mm_user_id,
|
||||||
|
"message": "First message",
|
||||||
|
"create_at": 1700000000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "p2",
|
||||||
|
"user_id": mm_user_id,
|
||||||
|
"message": "http://minio.local/chat/file.pdf",
|
||||||
|
"create_at": 1700000001000,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mm_client = Mock()
|
||||||
|
mm_client.get_posts.return_value = raw_posts
|
||||||
|
svc, _, _ = _make_service(publishers=[])
|
||||||
|
svc._mm = mm_client # inject after construction to keep _make_service simple
|
||||||
|
|
||||||
|
messages = svc.list_messages(conv.id, page=0, per_page=20)
|
||||||
|
|
||||||
|
assert len(messages) == 2
|
||||||
|
|
||||||
|
text_msg = messages[0]
|
||||||
|
assert text_msg["post_id"] == "p1"
|
||||||
|
assert text_msg["sender_id"] == sender_uuid
|
||||||
|
assert text_msg["message_type"] == "text"
|
||||||
|
assert text_msg["text"] == "First message"
|
||||||
|
assert text_msg["url"] is None
|
||||||
|
|
||||||
|
file_msg = messages[1]
|
||||||
|
assert file_msg["post_id"] == "p2"
|
||||||
|
assert file_msg["sender_id"] == sender_uuid
|
||||||
|
assert file_msg["message_type"] == "file"
|
||||||
|
assert file_msg["url"] == "http://minio.local/chat/file.pdf"
|
||||||
|
assert file_msg["text"] is None
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from apps.chat.models import Conversation, ConversationReadState
|
||||||
|
from apps.chat.services.read_state import ReadStateService
|
||||||
|
|
||||||
|
|
||||||
|
def _make_conv(channel_id="ch-read"):
|
||||||
|
return Conversation.objects.create(mattermost_channel_id=channel_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_mark_read_creates_read_state():
|
||||||
|
conv = _make_conv()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
svc = ReadStateService(mattermost_client=Mock())
|
||||||
|
read_state = svc.mark_read(conv.id, user_id, "post-111")
|
||||||
|
|
||||||
|
assert read_state.conversation_id == conv.id
|
||||||
|
assert read_state.user_id == user_id
|
||||||
|
assert read_state.last_read_mattermost_post_id == "post-111"
|
||||||
|
assert ConversationReadState.objects.filter(
|
||||||
|
conversation=conv, user_id=user_id
|
||||||
|
).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_mark_read_updates_existing():
|
||||||
|
conv = _make_conv()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
|
||||||
|
svc = ReadStateService(mattermost_client=Mock())
|
||||||
|
svc.mark_read(conv.id, user_id, "post-old")
|
||||||
|
svc.mark_read(conv.id, user_id, "post-new")
|
||||||
|
|
||||||
|
rows = ConversationReadState.objects.filter(conversation=conv, user_id=user_id)
|
||||||
|
assert rows.count() == 1
|
||||||
|
assert rows.first().last_read_mattermost_post_id == "post-new"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@patch("apps.chat.services.read_state.cache")
|
||||||
|
def test_has_unread_true(mock_cache):
|
||||||
|
mock_cache.get.return_value = None # force cache miss → MM lookup
|
||||||
|
|
||||||
|
conv = _make_conv()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
ConversationReadState.objects.create(
|
||||||
|
conversation=conv,
|
||||||
|
user_id=user_id,
|
||||||
|
last_read_mattermost_post_id="post-old",
|
||||||
|
)
|
||||||
|
|
||||||
|
mm_client = Mock()
|
||||||
|
mm_client.get_latest_post_id.return_value = "post-new"
|
||||||
|
|
||||||
|
svc = ReadStateService(mattermost_client=mm_client)
|
||||||
|
result = svc.has_unread(conv.id, user_id)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
mm_client.get_latest_post_id.assert_called_once_with(conv.mattermost_channel_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@patch("apps.chat.services.read_state.cache")
|
||||||
|
def test_has_unread_false(mock_cache):
|
||||||
|
mock_cache.get.return_value = None # force cache miss → MM lookup
|
||||||
|
|
||||||
|
conv = _make_conv()
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
ConversationReadState.objects.create(
|
||||||
|
conversation=conv,
|
||||||
|
user_id=user_id,
|
||||||
|
last_read_mattermost_post_id="post-current",
|
||||||
|
)
|
||||||
|
|
||||||
|
mm_client = Mock()
|
||||||
|
mm_client.get_latest_post_id.return_value = "post-current"
|
||||||
|
|
||||||
|
svc = ReadStateService(mattermost_client=mm_client)
|
||||||
|
result = svc.has_unread(conv.id, user_id)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConsumer(AsyncWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
|
||||||
|
self.group_name = f"chat_{self.chat_id}"
|
||||||
|
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def receive(self, text_data=None, bytes_data=None):
|
||||||
|
# Frontend receives only; messages are sent via HTTP API
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def chat_message(self, event):
|
||||||
|
"""Handle chat.message events forwarded from the channel layer."""
|
||||||
|
payload = {k: v for k, v in event.items() if k != "type"}
|
||||||
|
await self.send(text_data=json.dumps(payload))
|
||||||
7
apps/chat/websocket/routing.py
Normal file
7
apps/chat/websocket/routing.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.chat.websocket.consumers import ChatConsumer
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
path("ws/chat/<uuid:chat_id>/", ChatConsumer.as_asgi()),
|
||||||
|
]
|
||||||
22
conftest.py
Normal file
22
conftest.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def django_db_setup(django_test_environment, django_db_blocker):
|
||||||
|
"""
|
||||||
|
Override pytest-django's default DB setup to:
|
||||||
|
- Use the local unix-socket superuser instead of the .env credentials
|
||||||
|
- Remove psycopg3 connection pooling (pool=True conflicts with test
|
||||||
|
transaction wrapping and causes PoolTimeout during test-DB creation)
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test.utils import setup_databases
|
||||||
|
|
||||||
|
db = settings.DATABASES["default"]
|
||||||
|
db["USER"] = "hashdal"
|
||||||
|
db["PASSWORD"] = ""
|
||||||
|
db["HOST"] = ""
|
||||||
|
db["OPTIONS"] = {} # clear pool=True; psycopg3 pool conflicts with pytest-django
|
||||||
|
|
||||||
|
with django_db_blocker.unblock():
|
||||||
|
setup_databases(verbosity=0, interactive=False)
|
||||||
Binary file not shown.
26
main/asgi.py
26
main/asgi.py
|
|
@ -6,22 +6,22 @@ It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
|
|
||||||
from apps.chat.websocket import websocket_application
|
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings')
|
||||||
|
|
||||||
django_asgi_application = get_asgi_application()
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
async def application(scope, receive, send):
|
from channels.auth import AuthMiddlewareStack
|
||||||
if scope["type"] == "http":
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
await django_asgi_application(scope, receive, send)
|
|
||||||
return
|
|
||||||
|
|
||||||
if scope["type"] == "websocket":
|
from apps.chat.websocket.routing import websocket_urlpatterns
|
||||||
await websocket_application(scope, receive, send)
|
|
||||||
return
|
|
||||||
|
|
||||||
raise ValueError(f"Unsupported ASGI scope type: {scope['type']}")
|
application = ProtocolTypeRouter(
|
||||||
|
{
|
||||||
|
"http": django_asgi_app,
|
||||||
|
"websocket": AuthMiddlewareStack(
|
||||||
|
URLRouter(websocket_urlpatterns)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -223,9 +223,12 @@ SPECTACULAR_SETTINGS = {
|
||||||
'DESCRIPTION': 'service api reference',
|
'DESCRIPTION': 'service api reference',
|
||||||
'VERSION': '1.0.0',
|
'VERSION': '1.0.0',
|
||||||
'SERVE_INCLUDE_SCHEMA': False,
|
'SERVE_INCLUDE_SCHEMA': False,
|
||||||
# OTHER SETTINGS
|
'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'],
|
||||||
"PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"],
|
'SECURITY': [],
|
||||||
|
"PARSER_WHITELIST": [
|
||||||
|
"rest_framework.parsers.JSONParser",
|
||||||
|
"rest_framework.parsers.MultiPartParser",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
AUTH_USER_MODEL = 'users.User'
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
@ -254,6 +257,15 @@ CACHES = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||||
|
"CONFIG": {
|
||||||
|
"hosts": [config('REDIS_BASE_URL')],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
from main.other_settings.logging import get_logging_setting
|
from main.other_settings.logging import get_logging_setting
|
||||||
LOGGING = get_logging_setting()
|
LOGGING = get_logging_setting()
|
||||||
|
|
|
||||||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[pytest]
|
||||||
|
DJANGO_SETTINGS_MODULE = main.settings
|
||||||
Loading…
Add table
Reference in a new issue