Skip to content

Commit 8a30cad

Browse files
Merge pull request #170 from UNLV-CS472-672/unit-tests-for-chat
PR 6: Add WebSocket and JWT Middleware Unit Tests for Chat Functionality
2 parents c960397 + 5f0a58d commit 8a30cad

File tree

6 files changed

+288
-27
lines changed

6 files changed

+288
-27
lines changed

.github/workflows/django_ci_cd.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ jobs:
5353

5454
# Generate coverage XML report for Codecov
5555
- name: Run tests and generate coverage XML report
56-
run: pytest --cov --cov-branch --cov-report=xml
56+
run: |
57+
coverage erase
58+
pytest --cov --cov-branch --cov-report=xml
5759
5860
# Upload to Codecov (external visualization tool)
5961
- name: Upload coverage reports to Codecov

backend/apps/chat/consumers.py

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,22 +35,30 @@ async def receive(self, text_data):
3535
if 'message' in text_data_json:
3636
message = text_data_json['message']
3737

38-
# Create a new message object
39-
username, id, timestamp = await self.save_message(self.user_id, self.room_name, message)
40-
41-
# Serialize timestamp
42-
formatted = timestamp.isoformat()
43-
44-
await self.channel_layer.group_send( # sends message to all users in a channel group
45-
self.room_group_name,
46-
{
47-
'type': 'chat_message', # Calls chat_message()
48-
'message': message,
49-
'username': username,
50-
'id': id,
51-
'timestamp': formatted
52-
}
53-
)
38+
try:
39+
# Try to save the message
40+
username, id, timestamp = await self.save_message(self.user_id, self.room_name, message)
41+
42+
# Serialize timestamp
43+
formatted = timestamp.isoformat()
44+
45+
await self.channel_layer.group_send( # sends message to all users in a channel group
46+
self.room_group_name,
47+
{
48+
'type': 'chat_message', # Calls chat_message()
49+
'message': message,
50+
'username': username,
51+
'id': id,
52+
'timestamp': formatted
53+
}
54+
)
55+
except Chat.DoesNotExist:
56+
# Handle the error: chat room does not exist
57+
print(f"Chat room '{self.room_name}' not found. Message not saved.")
58+
# Send an error message back to the user
59+
await self.send(text_data=json.dumps({
60+
"error": f"Chat room '{self.room_name}' not found. Message not saved."
61+
}))
5462

5563
# Typing status is received
5664
elif 'typing' in text_data_json:
@@ -130,11 +138,7 @@ def get_username(self, id):
130138

131139
@sync_to_async
132140
def save_message(self, id, room_name, content):
133-
try:
134-
user = User.objects.get(id=id)
135-
chat = Chat.objects.get(name = room_name)
136-
message = Message.objects.create(content=content, chat = chat, sender = user) # Creates and saves message
137-
return user.username, message.id, message.timestamp
138-
except (Chat.DoesNotExist):
139-
print(f"Chat room '{room_name}' not found. Message not saved.")
140-
return "Unknown"
141+
user = User.objects.get(id=id)
142+
chat = Chat.objects.get(name=room_name)
143+
message = Message.objects.create(content=content, chat=chat, sender=user) # Creates and saves message
144+
return user.username, message.id, message.timestamp
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from django.contrib.auth.models import User
2+
from .models import Chat, Message
3+
from django.contrib.auth import get_user_model
4+
from .consumers import ChatConsumer
5+
from channels.testing import WebsocketCommunicator
6+
from django.conf import settings
7+
from backend.asgi import application
8+
from apps.users.models import Profile
9+
from rest_framework_simplejwt.tokens import RefreshToken
10+
import asyncio
11+
import pytest
12+
import json
13+
14+
@pytest.mark.asyncio
15+
@pytest.mark.django_db(transaction=True)
16+
class TestChatWebsockets:
17+
@pytest.fixture(autouse=True)
18+
def setup(self, db):
19+
self.user1 = get_user_model().objects.create_user(
20+
username='testuser1',
21+
password='password',
22+
first_name='John',
23+
last_name='Doe',
24+
email='testuser1@example.com'
25+
)
26+
self.profile1 = Profile.objects.create(user=self.user1, role=2)
27+
self.token1 = self.get_jwt_token(self.user1)
28+
29+
self.user2 = get_user_model().objects.create_user(
30+
username='testuser2',
31+
password='password',
32+
first_name='Jane',
33+
last_name='Doe',
34+
email='testuser2@example.com'
35+
)
36+
self.profile2 = Profile.objects.create(user=self.user2, role=2)
37+
self.token2 = self.get_jwt_token(self.user2)
38+
39+
# Create test chat
40+
self.chat, _ = Chat.objects.get_or_create_chat(self.user1, self.user2)
41+
self.chat.name = "room1"
42+
self.chat.save()
43+
44+
# Create test messages
45+
self.message1 = Message.objects.create(
46+
chat=self.chat,
47+
sender=self.user1,
48+
content="Test message 1 for chat",
49+
status=Message.NOT_SEEN
50+
)
51+
self.message2 = Message.objects.create(
52+
chat=self.chat,
53+
sender=self.user1,
54+
content="Test message 2 for chat",
55+
status=Message.NOT_SEEN
56+
)
57+
58+
def get_jwt_token(self, user):
59+
refresh = RefreshToken.for_user(user)
60+
return str(refresh.access_token)
61+
62+
def setup_communicator(self, room_name, token):
63+
return WebsocketCommunicator(
64+
application,
65+
f"/ws/apps/chat/{room_name}/",
66+
subprotocols=["chat", token],
67+
)
68+
69+
async def test_websocket_connection(self):
70+
communicator = self.setup_communicator("room1", self.token1)
71+
connected, _ = await communicator.connect()
72+
assert connected
73+
74+
await asyncio.sleep(0.1)
75+
76+
await communicator.send_to(text_data=json.dumps({"message": "ping"}))
77+
response = await communicator.receive_from()
78+
data = json.loads(response)
79+
80+
assert data["message"] == "successful"
81+
assert data["body"] == "ping"
82+
83+
await communicator.disconnect()
84+
85+
async def test_websocket_error_when_room_missing(self):
86+
# Pick a room name you never created in your fixtures or setup
87+
missing_room = "i_dont_exist"
88+
89+
# Set up communicator exactly the same way
90+
communicator = self.setup_communicator(missing_room, self.token1)
91+
connected, _ = await communicator.connect()
92+
assert connected
93+
94+
# Give the consumer a split-second to finish any setup
95+
await asyncio.sleep(0.1)
96+
97+
# Send a normal chat payload
98+
await communicator.send_to(text_data=json.dumps({"message": "hello"}))
99+
100+
# The consumer should catch Chat.DoesNotExist and send back an 'error' field
101+
response = await communicator.receive_from()
102+
data = json.loads(response)
103+
104+
assert "error" in data, "Expected an 'error' key when room is missing"
105+
assert data["error"] == f"Chat room '{missing_room}' not found. Message not saved."
106+
107+
await communicator.disconnect()
108+
109+
async def test_websocket_typing_status(self):
110+
communicator = self.setup_communicator("room1", self.token1)
111+
connected, _ = await communicator.connect()
112+
assert connected
113+
114+
await asyncio.sleep(0.1)
115+
116+
await communicator.send_to(text_data=json.dumps({"typing": True}))
117+
response = await communicator.receive_from()
118+
data = json.loads(response)
119+
120+
assert data["message"] == "successful"
121+
assert data["typing"] is True
122+
assert data["username"] == self.user1.username
123+
124+
await communicator.disconnect()
125+
126+
async def test_websocket_seen_status(self):
127+
communicator = self.setup_communicator("room1", self.token2) # User who sees the text
128+
connected, _ = await communicator.connect()
129+
assert connected
130+
131+
await asyncio.sleep(0.1)
132+
133+
seen_ids = [self.message1.id, self.message2.id]
134+
await communicator.send_to(text_data=json.dumps({"seen": seen_ids}))
135+
response = await communicator.receive_from()
136+
data = json.loads(response)
137+
138+
assert data["message"] == "seen_successful"
139+
assert sorted(data["ids"]) == sorted(seen_ids)
140+
assert data["username"] == self.user1.username
141+
142+
await communicator.disconnect()
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import pytest
2+
import asyncio
3+
from django.contrib.auth import get_user_model
4+
from rest_framework_simplejwt.tokens import AccessToken
5+
from channels.db import database_sync_to_async
6+
from channels.testing import ApplicationCommunicator
7+
from .jwt_middleware import JWTAuthMiddleware
8+
from apps.users.models import Profile
9+
from rest_framework_simplejwt.tokens import RefreshToken
10+
11+
User = get_user_model()
12+
13+
class DummyASGIApp:
14+
async def __call__(self, scope, receive, send):
15+
await send({
16+
'type': 'websocket.scope',
17+
'scope': scope
18+
})
19+
20+
21+
@pytest.mark.asyncio
22+
class TestJWTAuthMiddleware:
23+
24+
@pytest.fixture(autouse=True)
25+
def setup(self, db):
26+
self.user = get_user_model().objects.create_user(
27+
username='testuser',
28+
password='password',
29+
first_name='John',
30+
last_name='Doe',
31+
email='testuser1@example.com'
32+
)
33+
self.profile = Profile.objects.create(user=self.user, role=2)
34+
self.token = self.get_jwt_token(self.user)
35+
36+
def get_jwt_token(self, user):
37+
refresh = RefreshToken.for_user(user)
38+
return str(refresh.access_token)
39+
40+
async def test_jwt_auth_middleware_valid_token(self):
41+
scope = {
42+
"type": "websocket",
43+
"subprotocols": ["chat", self.token],
44+
}
45+
46+
middleware = JWTAuthMiddleware(DummyASGIApp())
47+
communicator = ApplicationCommunicator(middleware, scope)
48+
await communicator.send_input({'type': 'websocket.connect'})
49+
await communicator.wait()
50+
response = await communicator.receive_output()
51+
52+
assert response["scope"]["user_id"] == self.user.id
53+
assert "error" not in response["scope"]
54+
55+
async def test_jwt_auth_middleware_invalid_token(self):
56+
scope = {
57+
"type": "websocket",
58+
"subprotocols": ["chat", "invalid.token.here"],
59+
}
60+
61+
middleware = JWTAuthMiddleware(DummyASGIApp())
62+
communicator = ApplicationCommunicator(middleware, scope)
63+
await communicator.send_input({'type': 'websocket.connect'})
64+
await communicator.wait()
65+
response = await communicator.receive_output()
66+
67+
assert response["scope"].get("error") == "Invalid token"
68+
assert "user_id" not in response["scope"]
69+
70+
async def test_jwt_auth_middleware_missing_token(self):
71+
scope = {
72+
"type": "websocket",
73+
"subprotocols": ["chat"],
74+
}
75+
76+
middleware = JWTAuthMiddleware(DummyASGIApp())
77+
communicator = ApplicationCommunicator(middleware, scope)
78+
await communicator.send_input({'type': 'websocket.connect'})
79+
await communicator.wait()
80+
response = await communicator.receive_output()
81+
82+
assert response["scope"].get("error") == "Provide an auth token"
83+
assert "user_id" not in response["scope"]
84+
85+
async def test_jwt_auth_middleware_single_subprotocol(self):
86+
scope = {
87+
"type": "websocket",
88+
"subprotocols": ["chat"], # Only 1 subprotocol, should trigger missing token
89+
}
90+
91+
middleware = JWTAuthMiddleware(DummyASGIApp())
92+
communicator = ApplicationCommunicator(middleware, scope)
93+
await communicator.send_input({'type': 'websocket.connect'})
94+
await communicator.wait()
95+
response = await communicator.receive_output()
96+
97+
assert response["scope"].get("error") == "Provide an auth token"
98+
99+
async def test_jwt_auth_middleware_token_raises_exception(self):
100+
# Pass a token that will raise a decoding error
101+
broken_token = "a.b.c" # This will raise a DecodeError
102+
scope = {
103+
"type": "websocket",
104+
"subprotocols": ["chat", broken_token],
105+
}
106+
107+
middleware = JWTAuthMiddleware(DummyASGIApp())
108+
communicator = ApplicationCommunicator(middleware, scope)
109+
await communicator.send_input({'type': 'websocket.connect'})
110+
await communicator.wait()
111+
response = await communicator.receive_output()
112+
113+
assert response["scope"].get("error") == "Invalid token"

backend/apps/submissions/tests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def setUp(self):
6060

6161
url = reverse('token_obtain_pair') # Update to the correct token URL if necessary
6262
response = self.client.post(url, {'username': 'student1', 'password': 'password'}, format='json')
63-
self.token = response.data['access']
63+
self.token = response.data['access']
6464

6565

6666
### Testing Models ###

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# pytest.ini
22
[pytest]
33
DJANGO_SETTINGS_MODULE = backend.settings
4-
python_files = tests.py
4+
python_files = test_*.py *_tests.py tests.py

0 commit comments

Comments
 (0)