test(backend): convert existing app suites to unittest
This commit is contained in:
126
apps/notifications/tests/fakes.py
Normal file
126
apps/notifications/tests/fakes.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class FakePipeline:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
self.operations = []
|
||||
|
||||
def __getattr__(self, name):
|
||||
def wrapper(*args, **kwargs):
|
||||
self.operations.append((name, args, kwargs))
|
||||
return self
|
||||
|
||||
return wrapper
|
||||
|
||||
def execute(self):
|
||||
results = []
|
||||
for name, args, kwargs in self.operations:
|
||||
results.append(getattr(self.client, name)(*args, **kwargs))
|
||||
self.operations.clear()
|
||||
return results
|
||||
|
||||
|
||||
class FakePubSub:
|
||||
def __init__(self):
|
||||
self.channels = []
|
||||
self.messages = []
|
||||
self.closed = False
|
||||
|
||||
def subscribe(self, channel):
|
||||
self.channels.append(channel)
|
||||
|
||||
def unsubscribe(self, channel):
|
||||
if channel in self.channels:
|
||||
self.channels.remove(channel)
|
||||
|
||||
def get_message(self, timeout=1.0):
|
||||
if self.messages:
|
||||
return self.messages.pop(0)
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.sorted_sets = defaultdict(dict)
|
||||
self.hashes = defaultdict(dict)
|
||||
self.sets = defaultdict(set)
|
||||
self.published = []
|
||||
self.pubsub_instance = FakePubSub()
|
||||
|
||||
def pipeline(self):
|
||||
return FakePipeline(self)
|
||||
|
||||
def zadd(self, key, mapping):
|
||||
self.sorted_sets[key].update(mapping)
|
||||
return len(mapping)
|
||||
|
||||
def hset(self, key, field, value):
|
||||
self.hashes[key][field] = value
|
||||
return 1
|
||||
|
||||
def sadd(self, key, *members):
|
||||
before = len(self.sets[key])
|
||||
self.sets[key].update(members)
|
||||
return len(self.sets[key]) - before
|
||||
|
||||
def zrevrange(self, key, start, stop):
|
||||
items = sorted(
|
||||
self.sorted_sets[key].items(),
|
||||
key=lambda item: (item[1], item[0]),
|
||||
reverse=True,
|
||||
)
|
||||
if stop == -1:
|
||||
return [member for member, _ in items[start:]]
|
||||
return [member for member, _ in items[start : stop + 1]]
|
||||
|
||||
def hget(self, key, field):
|
||||
return self.hashes[key].get(field)
|
||||
|
||||
def zrem(self, key, *members):
|
||||
removed = 0
|
||||
for member in members:
|
||||
if member in self.sorted_sets[key]:
|
||||
del self.sorted_sets[key][member]
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def hdel(self, key, *fields):
|
||||
removed = 0
|
||||
for field in fields:
|
||||
if field in self.hashes[key]:
|
||||
del self.hashes[key][field]
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def smembers(self, key):
|
||||
return set(self.sets[key])
|
||||
|
||||
def srem(self, key, member):
|
||||
if member in self.sets[key]:
|
||||
self.sets[key].remove(member)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def zrangebyscore(self, key, min_score, max_score):
|
||||
lower = float("-inf") if min_score == "-inf" else float(min_score)
|
||||
upper = float(max_score)
|
||||
return [
|
||||
member
|
||||
for member, score in self.sorted_sets[key].items()
|
||||
if lower <= score <= upper
|
||||
]
|
||||
|
||||
def zcard(self, key):
|
||||
return len(self.sorted_sets[key])
|
||||
|
||||
def publish(self, channel, message):
|
||||
self.published.append((channel, json.loads(message)))
|
||||
return 1
|
||||
|
||||
def pubsub(self, ignore_subscribe_messages=True):
|
||||
return self.pubsub_instance
|
||||
@@ -1,159 +1,137 @@
|
||||
import pytest
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.notifications.services import store as services
|
||||
from apps.notifications.services import RedisNotificationStore
|
||||
from apps.notifications.tests.test_services import FakeRedis
|
||||
from apps.notifications.tests.fakes import FakeRedis
|
||||
from apps.users.models import User
|
||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_redis(monkeypatch):
|
||||
redis = FakeRedis()
|
||||
monkeypatch.setattr(services, "redis_client", redis)
|
||||
return redis
|
||||
class WorkspaceMembershipNotificationTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.owner = cls._create_user(1)
|
||||
cls.member = cls._create_user(2)
|
||||
|
||||
@staticmethod
|
||||
def _create_user(index):
|
||||
return User.objects.create_user(
|
||||
mobile=f"091200000{index:02d}",
|
||||
password="secret123",
|
||||
first_name=f"User{index}",
|
||||
)
|
||||
|
||||
@pytest.fixture()
|
||||
def api_client():
|
||||
return APIClient()
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.fake_redis = FakeRedis()
|
||||
self.original_redis_client = services.redis_client
|
||||
services.redis_client = self.fake_redis
|
||||
|
||||
def tearDown(self):
|
||||
services.redis_client = self.original_redis_client
|
||||
|
||||
def _create_user(index: int) -> User:
|
||||
return User.objects.create_user(
|
||||
mobile=f"091200000{index:02d}",
|
||||
password="secret123",
|
||||
first_name=f"User{index}",
|
||||
)
|
||||
@staticmethod
|
||||
def _notifications_for(user):
|
||||
notifications, _ = RedisNotificationStore.list(str(user.id), paginate=False)
|
||||
return notifications
|
||||
|
||||
def test_workspace_create_notifies_initial_members_not_owner(self):
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
|
||||
def _notifications_for(user):
|
||||
notifications, _ = RedisNotificationStore.list(
|
||||
str(user.id),
|
||||
paginate=False,
|
||||
)
|
||||
return notifications
|
||||
response = self.client.post(
|
||||
"/api/workspaces/",
|
||||
{
|
||||
"name": "Ops",
|
||||
"description": "Workspace",
|
||||
"members": [
|
||||
{
|
||||
"user_id": str(self.member.id),
|
||||
"role": WorkspaceMembership.Role.ADMIN,
|
||||
}
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(self._notifications_for(self.owner), [])
|
||||
member_notifications = self._notifications_for(self.member)
|
||||
self.assertEqual(len(member_notifications), 1)
|
||||
self.assertEqual(member_notifications[0]["type"], "workspace_membership_added")
|
||||
self.assertEqual(member_notifications[0]["meta"]["workspace_name"], "Ops")
|
||||
self.assertEqual(
|
||||
member_notifications[0]["meta"]["new_role"],
|
||||
WorkspaceMembership.Role.ADMIN,
|
||||
)
|
||||
|
||||
@pytest.fixture()
|
||||
def owner(db):
|
||||
return _create_user(1)
|
||||
def test_workspace_membership_crud_emits_all_expected_events(self):
|
||||
workspace = Workspace.objects.create(name="Design", description="", owner=self.owner)
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
|
||||
create_response = self.client.post(
|
||||
"/api/workspace-memberships/",
|
||||
{
|
||||
"workspace": str(workspace.id),
|
||||
"user": str(self.member.id),
|
||||
"role": WorkspaceMembership.Role.MEMBER,
|
||||
"is_active": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(create_response.status_code, 201)
|
||||
|
||||
@pytest.fixture()
|
||||
def member(db):
|
||||
return _create_user(2)
|
||||
membership_id = create_response.data["id"]
|
||||
notifications = self._notifications_for(self.member)
|
||||
self.assertEqual(
|
||||
[item["type"] for item in notifications],
|
||||
["workspace_membership_added"],
|
||||
)
|
||||
|
||||
role_response = self.client.patch(
|
||||
f"/api/workspace-memberships/{membership_id}/",
|
||||
{"role": WorkspaceMembership.Role.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(role_response.status_code, 200)
|
||||
|
||||
@pytest.fixture()
|
||||
def another_member(db):
|
||||
return _create_user(3)
|
||||
deactivate_response = self.client.patch(
|
||||
f"/api/workspace-memberships/{membership_id}/",
|
||||
{"is_active": False},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(deactivate_response.status_code, 200)
|
||||
|
||||
remove_response = self.client.delete(
|
||||
f"/api/workspace-memberships/{membership_id}/"
|
||||
)
|
||||
self.assertEqual(remove_response.status_code, 204)
|
||||
|
||||
@pytest.fixture()
|
||||
def third_member(db):
|
||||
return _create_user(4)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fourth_member(db):
|
||||
return _create_user(5)
|
||||
|
||||
|
||||
def test_workspace_create_notifies_initial_members_not_owner(
|
||||
fake_redis, api_client, owner, member
|
||||
):
|
||||
api_client.force_authenticate(user=owner)
|
||||
|
||||
response = api_client.post(
|
||||
"/api/workspaces/",
|
||||
{
|
||||
"name": "Ops",
|
||||
"description": "Workspace",
|
||||
"members": [
|
||||
{"user_id": str(member.id), "role": WorkspaceMembership.Role.ADMIN}
|
||||
notifications = self._notifications_for(self.member)
|
||||
self.assertEqual(
|
||||
[item["type"] for item in notifications],
|
||||
[
|
||||
"workspace_membership_removed",
|
||||
"workspace_membership_deactivated",
|
||||
"workspace_membership_role_changed",
|
||||
"workspace_membership_added",
|
||||
],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
owner_notifications = _notifications_for(owner)
|
||||
member_notifications = _notifications_for(member)
|
||||
def test_workspace_membership_update_skips_self_notifications(self):
|
||||
workspace = Workspace.objects.create(name="Product", description="", owner=self.owner)
|
||||
owner_membership = WorkspaceMembership.objects.get(
|
||||
workspace=workspace,
|
||||
user=self.owner,
|
||||
is_deleted=False,
|
||||
)
|
||||
self.client.force_authenticate(user=self.owner)
|
||||
|
||||
assert owner_notifications == []
|
||||
assert len(member_notifications) == 1
|
||||
assert member_notifications[0]["type"] == "workspace_membership_added"
|
||||
assert member_notifications[0]["meta"]["workspace_name"] == "Ops"
|
||||
assert member_notifications[0]["meta"]["new_role"] == WorkspaceMembership.Role.ADMIN
|
||||
|
||||
|
||||
def test_workspace_membership_crud_emits_add_role_change_deactivate_and_remove(
|
||||
fake_redis, api_client, owner, member
|
||||
):
|
||||
workspace = Workspace.objects.create(name="Design", description="", owner=owner)
|
||||
api_client.force_authenticate(user=owner)
|
||||
|
||||
create_response = api_client.post(
|
||||
"/api/workspace-memberships/",
|
||||
{
|
||||
"workspace": str(workspace.id),
|
||||
"user": str(member.id),
|
||||
"role": WorkspaceMembership.Role.MEMBER,
|
||||
"is_active": True,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
membership_id = create_response.data["id"]
|
||||
notifications = _notifications_for(member)
|
||||
assert [item["type"] for item in notifications] == ["workspace_membership_added"]
|
||||
|
||||
role_response = api_client.patch(
|
||||
f"/api/workspace-memberships/{membership_id}/",
|
||||
{"role": WorkspaceMembership.Role.ADMIN},
|
||||
format="json",
|
||||
)
|
||||
assert role_response.status_code == 200
|
||||
|
||||
deactivate_response = api_client.patch(
|
||||
f"/api/workspace-memberships/{membership_id}/",
|
||||
{"is_active": False},
|
||||
format="json",
|
||||
)
|
||||
assert deactivate_response.status_code == 200
|
||||
|
||||
remove_response = api_client.delete(f"/api/workspace-memberships/{membership_id}/")
|
||||
assert remove_response.status_code == 204
|
||||
|
||||
notifications = _notifications_for(member)
|
||||
assert [item["type"] for item in notifications] == [
|
||||
"workspace_membership_removed",
|
||||
"workspace_membership_deactivated",
|
||||
"workspace_membership_role_changed",
|
||||
"workspace_membership_added",
|
||||
]
|
||||
|
||||
|
||||
def test_workspace_membership_update_skips_self_notifications(
|
||||
fake_redis, api_client, owner
|
||||
):
|
||||
workspace = Workspace.objects.create(name="Product", description="", owner=owner)
|
||||
owner_membership = WorkspaceMembership.objects.get(
|
||||
workspace=workspace,
|
||||
user=owner,
|
||||
is_deleted=False,
|
||||
)
|
||||
api_client.force_authenticate(user=owner)
|
||||
|
||||
response = api_client.patch(
|
||||
f"/api/workspace-memberships/{owner_membership.id}/",
|
||||
{"role": WorkspaceMembership.Role.OWNER},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert _notifications_for(owner) == []
|
||||
response = self.client.patch(
|
||||
f"/api/workspace-memberships/{owner_membership.id}/",
|
||||
{"role": WorkspaceMembership.Role.OWNER},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(self._notifications_for(self.owner), [])
|
||||
|
||||
@@ -1,200 +1,78 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.notifications.services import store as services
|
||||
from apps.notifications.services import RedisNotificationStore
|
||||
from apps.notifications.tests.fakes import FakeRedis
|
||||
|
||||
|
||||
class FakePipeline:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
self.operations = []
|
||||
class RedisNotificationStoreTests(TestCase):
|
||||
def setUp(self):
|
||||
self.fake_redis = FakeRedis()
|
||||
self.original_redis_client = services.redis_client
|
||||
services.redis_client = self.fake_redis
|
||||
|
||||
def __getattr__(self, name):
|
||||
def wrapper(*args, **kwargs):
|
||||
self.operations.append((name, args, kwargs))
|
||||
return self
|
||||
def tearDown(self):
|
||||
services.redis_client = self.original_redis_client
|
||||
|
||||
return wrapper
|
||||
def test_add_publishes_notification_and_unread_count(self):
|
||||
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||
notification = RedisNotificationStore.add(
|
||||
"user-1",
|
||||
{
|
||||
"title": "Build finished",
|
||||
"message": "Your deploy completed.",
|
||||
"level": "success",
|
||||
},
|
||||
)
|
||||
|
||||
def execute(self):
|
||||
results = []
|
||||
for name, args, kwargs in self.operations:
|
||||
results.append(getattr(self.client, name)(*args, **kwargs))
|
||||
self.operations.clear()
|
||||
return results
|
||||
self.assertEqual(notification["title"], "Build finished")
|
||||
self.assertEqual(notification["message"], "Your deploy completed.")
|
||||
self.assertEqual(notification["level"], "success")
|
||||
self.assertEqual(len(self.fake_redis.published), 2)
|
||||
|
||||
|
||||
class FakePubSub:
|
||||
def __init__(self):
|
||||
self.channels = []
|
||||
self.messages = []
|
||||
self.closed = False
|
||||
|
||||
def subscribe(self, channel):
|
||||
self.channels.append(channel)
|
||||
|
||||
def unsubscribe(self, channel):
|
||||
if channel in self.channels:
|
||||
self.channels.remove(channel)
|
||||
|
||||
def get_message(self, timeout=1.0):
|
||||
if self.messages:
|
||||
return self.messages.pop(0)
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.sorted_sets = defaultdict(dict)
|
||||
self.hashes = defaultdict(dict)
|
||||
self.sets = defaultdict(set)
|
||||
self.published = []
|
||||
self.pubsub_instance = FakePubSub()
|
||||
|
||||
def pipeline(self):
|
||||
return FakePipeline(self)
|
||||
|
||||
def zadd(self, key, mapping):
|
||||
self.sorted_sets[key].update(mapping)
|
||||
return len(mapping)
|
||||
|
||||
def hset(self, key, field, value):
|
||||
self.hashes[key][field] = value
|
||||
return 1
|
||||
|
||||
def sadd(self, key, *members):
|
||||
before = len(self.sets[key])
|
||||
self.sets[key].update(members)
|
||||
return len(self.sets[key]) - before
|
||||
|
||||
def zrevrange(self, key, start, stop):
|
||||
items = sorted(
|
||||
self.sorted_sets[key].items(),
|
||||
key=lambda item: (item[1], item[0]),
|
||||
reverse=True,
|
||||
channel, payload = self.fake_redis.published[0]
|
||||
self.assertEqual(
|
||||
channel,
|
||||
f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1",
|
||||
)
|
||||
if stop == -1:
|
||||
return [member for member, _ in items[start:]]
|
||||
return [member for member, _ in items[start : stop + 1]]
|
||||
self.assertEqual(payload["event"], "notification")
|
||||
self.assertEqual(payload["data"]["notification"]["id"], notification["id"])
|
||||
self.assertEqual(payload["data"]["unread_count"], 1)
|
||||
|
||||
def hget(self, key, field):
|
||||
return self.hashes[key].get(field)
|
||||
def test_mark_seen_and_mark_all_seen_publish_sync_events(self):
|
||||
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
||||
RedisNotificationStore.add("user-2", {"title": "Second"})
|
||||
self.fake_redis.published.clear()
|
||||
|
||||
def zrem(self, key, *members):
|
||||
removed = 0
|
||||
for member in members:
|
||||
if member in self.sorted_sets[key]:
|
||||
del self.sorted_sets[key][member]
|
||||
removed += 1
|
||||
return removed
|
||||
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
|
||||
|
||||
def hdel(self, key, *fields):
|
||||
removed = 0
|
||||
for field in fields:
|
||||
if field in self.hashes[key]:
|
||||
del self.hashes[key][field]
|
||||
removed += 1
|
||||
return removed
|
||||
self.assertEqual(payload["notification_id"], first["id"])
|
||||
self.assertFalse(payload["deleted"])
|
||||
self.assertTrue(payload["notification"]["is_seen"])
|
||||
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_seen")
|
||||
|
||||
def smembers(self, key):
|
||||
return set(self.sets[key])
|
||||
self.fake_redis.published.clear()
|
||||
updated = RedisNotificationStore.mark_all_seen("user-2")
|
||||
|
||||
def srem(self, key, member):
|
||||
if member in self.sets[key]:
|
||||
self.sets[key].remove(member)
|
||||
return 1
|
||||
return 0
|
||||
self.assertEqual(updated, 2)
|
||||
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_mark_all_read")
|
||||
self.assertEqual(self.fake_redis.published[1][1]["event"], "unread_count")
|
||||
self.assertEqual(self.fake_redis.published[1][1]["data"]["unread_count"], 0)
|
||||
|
||||
def zrangebyscore(self, key, min_score, max_score):
|
||||
lower = float("-inf") if min_score == "-inf" else float(min_score)
|
||||
upper = float(max_score)
|
||||
return [
|
||||
member
|
||||
for member, score in self.sorted_sets[key].items()
|
||||
if lower <= score <= upper
|
||||
]
|
||||
def test_list_returns_total_count_and_filtered_notifications(self):
|
||||
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
|
||||
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
|
||||
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
|
||||
|
||||
def zcard(self, key):
|
||||
return len(self.sorted_sets[key])
|
||||
notifications, total_count = RedisNotificationStore.list(
|
||||
"user-3",
|
||||
limit=1,
|
||||
offset=0,
|
||||
type_filter="general",
|
||||
)
|
||||
|
||||
def publish(self, channel, message):
|
||||
self.published.append((channel, json.loads(message)))
|
||||
return 1
|
||||
|
||||
def pubsub(self, ignore_subscribe_messages=True):
|
||||
return self.pubsub_instance
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_redis(monkeypatch):
|
||||
redis = FakeRedis()
|
||||
monkeypatch.setattr(services, "redis_client", redis)
|
||||
return redis
|
||||
|
||||
|
||||
def test_add_publishes_notification_and_unread_count(fake_redis, settings):
|
||||
settings.NOTIFICATIONS_ENABLED = True
|
||||
|
||||
notification = RedisNotificationStore.add(
|
||||
"user-1",
|
||||
{
|
||||
"title": "Build finished",
|
||||
"message": "Your deploy completed.",
|
||||
"level": "success",
|
||||
},
|
||||
)
|
||||
|
||||
assert notification["title"] == "Build finished"
|
||||
assert notification["message"] == "Your deploy completed."
|
||||
assert notification["level"] == "success"
|
||||
assert len(fake_redis.published) == 2
|
||||
channel, payload = fake_redis.published[0]
|
||||
assert channel == f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1"
|
||||
assert payload["event"] == "notification"
|
||||
assert payload["data"]["notification"]["id"] == notification["id"]
|
||||
assert payload["data"]["unread_count"] == 1
|
||||
|
||||
|
||||
def test_mark_seen_and_mark_all_seen_publish_sync_events(fake_redis, settings):
|
||||
settings.NOTIFICATIONS_ENABLED = True
|
||||
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
||||
second = RedisNotificationStore.add("user-2", {"title": "Second"})
|
||||
fake_redis.published.clear()
|
||||
|
||||
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
|
||||
|
||||
assert payload["notification_id"] == first["id"]
|
||||
assert payload["deleted"] is False
|
||||
assert payload["notification"]["is_seen"] is True
|
||||
assert fake_redis.published[0][1]["event"] == "notification_seen"
|
||||
|
||||
fake_redis.published.clear()
|
||||
updated = RedisNotificationStore.mark_all_seen("user-2")
|
||||
|
||||
assert updated == 2
|
||||
assert fake_redis.published[0][1]["event"] == "notification_mark_all_read"
|
||||
assert fake_redis.published[1][1]["event"] == "unread_count"
|
||||
assert fake_redis.published[1][1]["data"]["unread_count"] == 0
|
||||
|
||||
|
||||
def test_list_returns_total_count_and_filtered_notifications(fake_redis):
|
||||
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
|
||||
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
|
||||
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
|
||||
|
||||
notifications, total_count = RedisNotificationStore.list(
|
||||
"user-3",
|
||||
limit=1,
|
||||
offset=0,
|
||||
type_filter="general",
|
||||
)
|
||||
|
||||
assert total_count == 2
|
||||
assert len(notifications) == 1
|
||||
assert notifications[0]["type"] == "general"
|
||||
self.assertEqual(total_count, 2)
|
||||
self.assertEqual(len(notifications), 1)
|
||||
self.assertEqual(notifications[0]["type"], "general")
|
||||
|
||||
@@ -1,166 +1,168 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.notifications.api import views
|
||||
from apps.notifications.services import store as services
|
||||
from apps.notifications.services import RedisNotificationStore
|
||||
from apps.notifications.tests.test_services import FakePubSub, FakeRedis
|
||||
from apps.notifications.tests.fakes import FakePubSub, FakeRedis
|
||||
from apps.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_redis(monkeypatch):
|
||||
redis = FakeRedis()
|
||||
monkeypatch.setattr(services, "redis_client", redis)
|
||||
return redis
|
||||
class NotificationViewTests(APITestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
|
||||
cls.second_user = User.objects.create_user(
|
||||
mobile="09122222222",
|
||||
password="secret123",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.fake_redis = FakeRedis()
|
||||
self.original_redis_client = services.redis_client
|
||||
services.redis_client = self.fake_redis
|
||||
|
||||
@pytest.fixture()
|
||||
def user(db):
|
||||
return User.objects.create_user(mobile="09121111111", password="secret123")
|
||||
def tearDown(self):
|
||||
services.redis_client = self.original_redis_client
|
||||
|
||||
@staticmethod
|
||||
def _read_sse_chunks(response, count):
|
||||
iterator = iter(response.streaming_content)
|
||||
chunks = []
|
||||
for _ in range(count):
|
||||
chunk = next(iterator)
|
||||
if isinstance(chunk, bytes):
|
||||
chunk = chunk.decode("utf-8")
|
||||
chunks.append(chunk)
|
||||
response.close()
|
||||
return chunks
|
||||
|
||||
@pytest.fixture()
|
||||
def second_user(db):
|
||||
return User.objects.create_user(mobile="09122222222", password="secret123")
|
||||
@staticmethod
|
||||
def _parse_sse_data(chunk):
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("data: "):
|
||||
return json.loads(line.removeprefix("data: "))
|
||||
raise AssertionError("SSE payload did not include data")
|
||||
|
||||
def test_stream_token_endpoint_returns_short_lived_token(self):
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def _read_sse_chunks(response, count):
|
||||
iterator = iter(response.streaming_content)
|
||||
chunks = []
|
||||
for _ in range(count):
|
||||
chunk = next(iterator)
|
||||
if isinstance(chunk, bytes):
|
||||
chunk = chunk.decode("utf-8")
|
||||
chunks.append(chunk)
|
||||
response.close()
|
||||
return chunks
|
||||
response = self.client.post("/api/notifications/stream-token/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data["token"])
|
||||
self.assertGreater(response.data["expires_in"], 0)
|
||||
|
||||
def _parse_sse_data(chunk: str) -> dict:
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("data: "):
|
||||
return json.loads(line.removeprefix("data: "))
|
||||
raise AssertionError("SSE payload did not include data")
|
||||
def test_stream_endpoint_rejects_missing_and_expired_token(self):
|
||||
missing = self.client.get("/api/notifications/stream/")
|
||||
self.assertEqual(missing.status_code, 401)
|
||||
|
||||
with override_settings(NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS=1):
|
||||
token = views._issue_stream_token_for_user(str(self.user.id))
|
||||
time.sleep(1.1)
|
||||
expired = self.client.get(f"/api/notifications/stream/?token={token}")
|
||||
|
||||
def test_stream_token_endpoint_returns_short_lived_token(user):
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
self.assertEqual(expired.status_code, 401)
|
||||
|
||||
response = client.post("/api/notifications/stream-token/")
|
||||
def test_stream_endpoint_sends_only_current_users_notifications(self):
|
||||
RedisNotificationStore.add(str(self.user.id), {"title": "For current user"})
|
||||
RedisNotificationStore.add(str(self.second_user.id), {"title": "For another user"})
|
||||
pubsub = FakePubSub()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["token"]
|
||||
assert response.data["expires_in"] > 0
|
||||
with patch.object(
|
||||
RedisNotificationStore,
|
||||
"get_pubsub",
|
||||
classmethod(lambda cls: pubsub),
|
||||
):
|
||||
token = views._issue_stream_token_for_user(str(self.user.id))
|
||||
response = self.client.get(
|
||||
f"/api/notifications/stream/?token={token}",
|
||||
HTTP_ACCEPT="text/event-stream",
|
||||
)
|
||||
retry_line, connected_chunk = self._read_sse_chunks(response, 2)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(retry_line.startswith("retry:"))
|
||||
connected = self._parse_sse_data(connected_chunk)
|
||||
self.assertEqual(connected["unread_count"], 1)
|
||||
self.assertEqual(
|
||||
[item["title"] for item in connected["notifications"]],
|
||||
["For current user"],
|
||||
)
|
||||
|
||||
def test_stream_endpoint_rejects_missing_and_expired_token(user, settings):
|
||||
client = APIClient()
|
||||
def test_stream_endpoint_emits_heartbeat(self):
|
||||
pubsub = FakePubSub()
|
||||
first_now = timezone.now()
|
||||
tick_values = iter(
|
||||
[
|
||||
first_now,
|
||||
first_now,
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
]
|
||||
)
|
||||
last_tick = first_now + timedelta(seconds=2)
|
||||
|
||||
missing = client.get("/api/notifications/stream/")
|
||||
assert missing.status_code == 401
|
||||
def fake_now():
|
||||
return next(tick_values, last_tick)
|
||||
|
||||
settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS = 1
|
||||
token = views._issue_stream_token_for_user(str(user.id))
|
||||
time.sleep(1.1)
|
||||
with override_settings(NOTIFICATION_SSE_HEARTBEAT_SECONDS=1):
|
||||
with patch.object(
|
||||
RedisNotificationStore,
|
||||
"get_pubsub",
|
||||
classmethod(lambda cls: pubsub),
|
||||
):
|
||||
with patch.object(views.timezone, "now", side_effect=fake_now):
|
||||
view = views.NotificationStreamView()
|
||||
stream = view._build_stream(str(self.user.id))
|
||||
chunks = [next(stream) for _ in range(4)]
|
||||
stream.close()
|
||||
|
||||
expired = client.get(f"/api/notifications/stream/?token={token}")
|
||||
assert expired.status_code == 401
|
||||
self.assertIn("event: ping", chunks[3])
|
||||
|
||||
def test_notification_list_and_seen_endpoints_work(self):
|
||||
notification = RedisNotificationStore.add(
|
||||
str(self.user.id),
|
||||
{"title": "Deploy succeeded", "type": "deploy"},
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_stream_endpoint_sends_only_current_users_notifications(
|
||||
fake_redis, user, second_user, monkeypatch
|
||||
):
|
||||
RedisNotificationStore.add(str(user.id), {"title": "For current user"})
|
||||
RedisNotificationStore.add(str(second_user.id), {"title": "For another user"})
|
||||
pubsub = FakePubSub()
|
||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
||||
token = views._issue_stream_token_for_user(str(user.id))
|
||||
list_response = self.client.get("/api/notifications/list/?type=deploy")
|
||||
self.assertEqual(list_response.status_code, 200)
|
||||
self.assertEqual(list_response.data["count"], 1)
|
||||
self.assertEqual(list_response.data["unread_count"], 1)
|
||||
self.assertEqual(
|
||||
list_response.data["notifications"][0]["title"],
|
||||
"Deploy succeeded",
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(
|
||||
f"/api/notifications/stream/?token={token}",
|
||||
HTTP_ACCEPT="text/event-stream",
|
||||
)
|
||||
retry_line, connected_chunk = _read_sse_chunks(response, 2)
|
||||
seen_response = self.client.post(
|
||||
"/api/notifications/seen/",
|
||||
{"id": notification["id"]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(seen_response.status_code, 200)
|
||||
self.assertTrue(seen_response.data["marked_read"])
|
||||
self.assertTrue(seen_response.data["notification"]["is_seen"])
|
||||
|
||||
assert response.status_code == 200
|
||||
assert retry_line.startswith("retry:")
|
||||
connected = _parse_sse_data(connected_chunk)
|
||||
assert connected["unread_count"] == 1
|
||||
assert [item["title"] for item in connected["notifications"]] == ["For current user"]
|
||||
def test_notification_delete_endpoint_removes_notification(self):
|
||||
notification = RedisNotificationStore.add(
|
||||
str(self.user.id),
|
||||
{"title": "Delete me", "type": "deploy"},
|
||||
)
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
response = self.client.delete(f"/api/notifications/{notification['id']}/")
|
||||
|
||||
def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch):
|
||||
pubsub = FakePubSub()
|
||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
||||
settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 1
|
||||
|
||||
first_now = timezone.now()
|
||||
tick_values = iter(
|
||||
[
|
||||
first_now,
|
||||
first_now,
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
first_now + timedelta(seconds=2),
|
||||
]
|
||||
)
|
||||
last_tick = first_now + timedelta(seconds=2)
|
||||
|
||||
def fake_now():
|
||||
return next(tick_values, last_tick)
|
||||
|
||||
monkeypatch.setattr(views.timezone, "now", fake_now)
|
||||
view = views.NotificationStreamView()
|
||||
stream = view._build_stream(str(user.id))
|
||||
|
||||
chunks = [next(stream) for _ in range(4)]
|
||||
stream.close()
|
||||
|
||||
assert "event: ping" in chunks[3]
|
||||
|
||||
|
||||
def test_notification_list_and_seen_endpoints_work(fake_redis, user):
|
||||
notification = RedisNotificationStore.add(
|
||||
str(user.id),
|
||||
{"title": "Deploy succeeded", "type": "deploy"},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
list_response = client.get("/api/notifications/list/?type=deploy")
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.data["count"] == 1
|
||||
assert list_response.data["unread_count"] == 1
|
||||
assert list_response.data["notifications"][0]["title"] == "Deploy succeeded"
|
||||
|
||||
seen_response = client.post("/api/notifications/seen/", {"id": notification["id"]}, format="json")
|
||||
assert seen_response.status_code == 200
|
||||
assert seen_response.data["marked_read"] is True
|
||||
assert seen_response.data["notification"]["is_seen"] is True
|
||||
|
||||
|
||||
def test_notification_delete_endpoint_removes_notification(fake_redis, user):
|
||||
notification = RedisNotificationStore.add(
|
||||
str(user.id),
|
||||
{"title": "Delete me", "type": "deploy"},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
response = client.delete(f"/api/notifications/{notification['id']}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["deleted"] is True
|
||||
assert response.data["notification_id"] == notification["id"]
|
||||
assert RedisNotificationStore.get(str(user.id), notification["id"]) is None
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data["deleted"])
|
||||
self.assertEqual(response.data["notification_id"], notification["id"])
|
||||
self.assertIsNone(RedisNotificationStore.get(str(self.user.id), notification["id"]))
|
||||
|
||||
Reference in New Issue
Block a user