test(backend): convert existing app suites to unittest
This commit is contained in:
@@ -1,23 +1,40 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
from rest_framework_simplejwt.tokens import AccessToken
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.reports.models import ReportExportJob
|
from apps.reports.models import ReportExportJob
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class WorkspaceLogViewTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
def setUpTestData(cls):
|
||||||
|
cls.owner = cls._user(1)
|
||||||
|
cls.admin = cls._user(2)
|
||||||
|
cls.member = cls._user(3)
|
||||||
|
cls.outsider = cls._user(4)
|
||||||
|
|
||||||
|
cls.workspace = Workspace.objects.create(
|
||||||
|
name="Logs WS",
|
||||||
|
description="",
|
||||||
|
owner=cls.owner,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.admin,
|
||||||
|
role=WorkspaceMembership.Role.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
def _user(index: int) -> User:
|
@staticmethod
|
||||||
|
def _user(index):
|
||||||
return User.objects.create_user(
|
return User.objects.create_user(
|
||||||
mobile=f"093355500{index:02d}",
|
mobile=f"093355500{index:02d}",
|
||||||
password="secret123",
|
password="secret123",
|
||||||
@@ -25,157 +42,120 @@ def _user(index: int) -> User:
|
|||||||
last_name="User",
|
last_name="User",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
@pytest.fixture()
|
def _auth_headers(user):
|
||||||
def owner(db):
|
|
||||||
return _user(1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def admin(db):
|
|
||||||
return _user(2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def member(db):
|
|
||||||
return _user(3)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def outsider(db):
|
|
||||||
return _user(4)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def workspace(owner, admin, member):
|
|
||||||
workspace = Workspace.objects.create(name="Logs WS", description="", owner=owner)
|
|
||||||
WorkspaceMembership.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=admin,
|
|
||||||
role=WorkspaceMembership.Role.ADMIN,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
WorkspaceMembership.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=member,
|
|
||||||
role=WorkspaceMembership.Role.MEMBER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
return workspace
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_headers(user: User) -> dict:
|
|
||||||
token = str(AccessToken.for_user(user))
|
token = str(AccessToken.for_user(user))
|
||||||
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
return {"HTTP_AUTHORIZATION": f"Bearer {token}"}
|
||||||
|
|
||||||
|
def _create_tag(self, user, *, name="Audit Tag"):
|
||||||
def _create_tag(client: APIClient, user: User, workspace: Workspace, *, name="Audit Tag"):
|
return self.client.post(
|
||||||
return client.post(
|
|
||||||
"/api/tags/",
|
"/api/tags/",
|
||||||
{"workspace_id": str(workspace.id), "name": name, "color": "#123456"},
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": name,
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
**_auth_headers(user),
|
**self._auth_headers(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_owner_and_admin_can_list_workspace_logs(self):
|
||||||
|
create_response = self._create_tag(self.owner)
|
||||||
|
self.assertEqual(create_response.status_code, 201)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
owner_response = self.client.get(
|
||||||
def test_owner_and_admin_can_list_workspace_logs(api_client, owner, admin, workspace):
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
create_response = _create_tag(api_client, owner, workspace)
|
**self._auth_headers(self.owner),
|
||||||
assert create_response.status_code == 201
|
|
||||||
|
|
||||||
owner_response = api_client.get(
|
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
|
||||||
**_auth_headers(owner),
|
|
||||||
)
|
)
|
||||||
admin_response = api_client.get(
|
admin_response = self.client.get(
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
**_auth_headers(admin),
|
**self._auth_headers(self.admin),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert owner_response.status_code == 200
|
self.assertEqual(owner_response.status_code, 200)
|
||||||
assert admin_response.status_code == 200
|
self.assertEqual(admin_response.status_code, 200)
|
||||||
assert owner_response.data["items"][0]["section"] == "tags"
|
self.assertEqual(owner_response.data["items"][0]["section"], "tags")
|
||||||
|
|
||||||
|
def test_member_and_non_member_cannot_list_workspace_logs(self):
|
||||||
|
self._create_tag(self.owner)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
member_response = self.client.get(
|
||||||
def test_member_and_non_member_cannot_list_workspace_logs(api_client, owner, member, outsider, workspace):
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
_create_tag(api_client, owner, workspace)
|
**self._auth_headers(self.member),
|
||||||
|
|
||||||
member_response = api_client.get(
|
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
|
||||||
**_auth_headers(member),
|
|
||||||
)
|
)
|
||||||
outsider_response = api_client.get(
|
outsider_response = self.client.get(
|
||||||
f"/api/logs/?workspace={workspace.id}",
|
f"/api/logs/?workspace={self.workspace.id}",
|
||||||
**_auth_headers(outsider),
|
**self._auth_headers(self.outsider),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert member_response.status_code == 403
|
self.assertEqual(member_response.status_code, 403)
|
||||||
assert outsider_response.status_code == 403
|
self.assertEqual(outsider_response.status_code, 403)
|
||||||
|
|
||||||
|
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(self):
|
||||||
|
response = self._create_tag(self.owner, name="JWT Tag")
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest(
|
||||||
def test_jwt_authenticated_writes_capture_actor_and_workspace_metadata(api_client, owner, workspace):
|
"timestamp"
|
||||||
response = _create_tag(api_client, owner, workspace, name="JWT Tag")
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
log_entry = LogEntry.objects.filter(content_type__app_label="tags").latest("timestamp")
|
|
||||||
|
|
||||||
assert log_entry.actor_id == owner.id
|
|
||||||
assert log_entry.additional_data["workspace_id"] == str(workspace.id)
|
|
||||||
assert log_entry.additional_data["section"] == "tags"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_logs_support_section_filter_and_detail(api_client, owner, workspace):
|
|
||||||
tag_response = _create_tag(api_client, owner, workspace, name="Filtered Tag")
|
|
||||||
assert tag_response.status_code == 201
|
|
||||||
|
|
||||||
list_response = api_client.get(
|
|
||||||
f"/api/logs/?workspace={workspace.id}§ion=tags",
|
|
||||||
**_auth_headers(owner),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert list_response.status_code == 200
|
self.assertEqual(log_entry.actor_id, self.owner.id)
|
||||||
assert list_response.data["items"]
|
self.assertEqual(log_entry.additional_data["workspace_id"], str(self.workspace.id))
|
||||||
|
self.assertEqual(log_entry.additional_data["section"], "tags")
|
||||||
|
|
||||||
|
def test_logs_support_section_filter_and_detail(self):
|
||||||
|
tag_response = self._create_tag(self.owner, name="Filtered Tag")
|
||||||
|
self.assertEqual(tag_response.status_code, 201)
|
||||||
|
|
||||||
|
list_response = self.client.get(
|
||||||
|
f"/api/logs/?workspace={self.workspace.id}§ion=tags",
|
||||||
|
**self._auth_headers(self.owner),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(list_response.status_code, 200)
|
||||||
|
self.assertTrue(list_response.data["items"])
|
||||||
log_id = list_response.data["items"][0]["id"]
|
log_id = list_response.data["items"][0]["id"]
|
||||||
|
|
||||||
detail_response = api_client.get(
|
detail_response = self.client.get(
|
||||||
f"/api/logs/{log_id}/",
|
f"/api/logs/{log_id}/",
|
||||||
**_auth_headers(owner),
|
**self._auth_headers(self.owner),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert detail_response.status_code == 200
|
self.assertEqual(detail_response.status_code, 200)
|
||||||
assert detail_response.data["target"]["name"] == "Filtered Tag"
|
self.assertEqual(detail_response.data["target"]["name"], "Filtered Tag")
|
||||||
assert detail_response.data["changes"]
|
self.assertTrue(detail_response.data["changes"])
|
||||||
|
|
||||||
|
def test_soft_delete_and_actorless_background_logs_are_filtered(self):
|
||||||
@pytest.mark.django_db
|
create_response = self._create_tag(self.owner, name="Delete Me")
|
||||||
def test_soft_delete_and_actorless_background_logs_are_filtered(api_client, owner, workspace):
|
self.assertEqual(create_response.status_code, 201)
|
||||||
create_response = _create_tag(api_client, owner, workspace, name="Delete Me")
|
|
||||||
assert create_response.status_code == 201
|
|
||||||
tag_id = create_response.data["id"]
|
tag_id = create_response.data["id"]
|
||||||
|
|
||||||
delete_response = api_client.delete(
|
delete_response = self.client.delete(
|
||||||
f"/api/tags/{tag_id}/",
|
f"/api/tags/{tag_id}/",
|
||||||
**_auth_headers(owner),
|
**self._auth_headers(self.owner),
|
||||||
)
|
)
|
||||||
assert delete_response.status_code == 204
|
self.assertEqual(delete_response.status_code, 204)
|
||||||
|
|
||||||
ReportExportJob.objects.create(
|
ReportExportJob.objects.create(
|
||||||
requesting_user=owner,
|
requesting_user=self.owner,
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
export_type=ReportExportJob.ExportType.PDF,
|
export_type=ReportExportJob.ExportType.PDF,
|
||||||
filters={"workspace": str(workspace.id)},
|
filters={"workspace": str(self.workspace.id)},
|
||||||
status=ReportExportJob.Status.PENDING,
|
status=ReportExportJob.Status.PENDING,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = api_client.get(
|
response = self.client.get(
|
||||||
f"/api/logs/?workspace={workspace.id}&event=delete",
|
f"/api/logs/?workspace={self.workspace.id}&event=delete",
|
||||||
**_auth_headers(owner),
|
**self._auth_headers(self.owner),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert any(item["event"] == "delete" and item["section"] == "tags" for item in response.data["items"])
|
self.assertTrue(
|
||||||
assert all(item["section"] != "report_exports" for item in response.data["items"])
|
any(
|
||||||
|
item["event"] == "delete" and item["section"] == "tags"
|
||||||
|
for item in response.data["items"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(item["section"] != "report_exports" for item in response.data["items"])
|
||||||
|
)
|
||||||
|
|||||||
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 rest_framework.test import APIClient
|
||||||
|
|
||||||
from apps.notifications.services import store as services
|
from apps.notifications.services import store as services
|
||||||
from apps.notifications.services import RedisNotificationStore
|
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.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class WorkspaceMembershipNotificationTests(TestCase):
|
||||||
def fake_redis(monkeypatch):
|
@classmethod
|
||||||
redis = FakeRedis()
|
def setUpTestData(cls):
|
||||||
monkeypatch.setattr(services, "redis_client", redis)
|
cls.owner = cls._create_user(1)
|
||||||
return redis
|
cls.member = cls._create_user(2)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
@pytest.fixture()
|
def _create_user(index):
|
||||||
def api_client():
|
|
||||||
return APIClient()
|
|
||||||
|
|
||||||
|
|
||||||
def _create_user(index: int) -> User:
|
|
||||||
return User.objects.create_user(
|
return User.objects.create_user(
|
||||||
mobile=f"091200000{index:02d}",
|
mobile=f"091200000{index:02d}",
|
||||||
password="secret123",
|
password="secret123",
|
||||||
first_name=f"User{index}",
|
first_name=f"User{index}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _notifications_for(user):
|
def _notifications_for(user):
|
||||||
notifications, _ = RedisNotificationStore.list(
|
notifications, _ = RedisNotificationStore.list(str(user.id), paginate=False)
|
||||||
str(user.id),
|
|
||||||
paginate=False,
|
|
||||||
)
|
|
||||||
return notifications
|
return notifications
|
||||||
|
|
||||||
|
def test_workspace_create_notifies_initial_members_not_owner(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
@pytest.fixture()
|
response = self.client.post(
|
||||||
def owner(db):
|
|
||||||
return _create_user(1)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def member(db):
|
|
||||||
return _create_user(2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def another_member(db):
|
|
||||||
return _create_user(3)
|
|
||||||
|
|
||||||
|
|
||||||
@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/",
|
"/api/workspaces/",
|
||||||
{
|
{
|
||||||
"name": "Ops",
|
"name": "Ops",
|
||||||
"description": "Workspace",
|
"description": "Workspace",
|
||||||
"members": [
|
"members": [
|
||||||
{"user_id": str(member.id), "role": WorkspaceMembership.Role.ADMIN}
|
{
|
||||||
|
"user_id": str(self.member.id),
|
||||||
|
"role": WorkspaceMembership.Role.ADMIN,
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 201
|
self.assertEqual(response.status_code, 201)
|
||||||
owner_notifications = _notifications_for(owner)
|
self.assertEqual(self._notifications_for(self.owner), [])
|
||||||
member_notifications = _notifications_for(member)
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
assert owner_notifications == []
|
def test_workspace_membership_crud_emits_all_expected_events(self):
|
||||||
assert len(member_notifications) == 1
|
workspace = Workspace.objects.create(name="Design", description="", owner=self.owner)
|
||||||
assert member_notifications[0]["type"] == "workspace_membership_added"
|
self.client.force_authenticate(user=self.owner)
|
||||||
assert member_notifications[0]["meta"]["workspace_name"] == "Ops"
|
|
||||||
assert member_notifications[0]["meta"]["new_role"] == WorkspaceMembership.Role.ADMIN
|
|
||||||
|
|
||||||
|
create_response = self.client.post(
|
||||||
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/",
|
"/api/workspace-memberships/",
|
||||||
{
|
{
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(workspace.id),
|
||||||
"user": str(member.id),
|
"user": str(self.member.id),
|
||||||
"role": WorkspaceMembership.Role.MEMBER,
|
"role": WorkspaceMembership.Role.MEMBER,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert create_response.status_code == 201
|
self.assertEqual(create_response.status_code, 201)
|
||||||
|
|
||||||
membership_id = create_response.data["id"]
|
membership_id = create_response.data["id"]
|
||||||
notifications = _notifications_for(member)
|
notifications = self._notifications_for(self.member)
|
||||||
assert [item["type"] for item in notifications] == ["workspace_membership_added"]
|
self.assertEqual(
|
||||||
|
[item["type"] for item in notifications],
|
||||||
|
["workspace_membership_added"],
|
||||||
|
)
|
||||||
|
|
||||||
role_response = api_client.patch(
|
role_response = self.client.patch(
|
||||||
f"/api/workspace-memberships/{membership_id}/",
|
f"/api/workspace-memberships/{membership_id}/",
|
||||||
{"role": WorkspaceMembership.Role.ADMIN},
|
{"role": WorkspaceMembership.Role.ADMIN},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert role_response.status_code == 200
|
self.assertEqual(role_response.status_code, 200)
|
||||||
|
|
||||||
deactivate_response = api_client.patch(
|
deactivate_response = self.client.patch(
|
||||||
f"/api/workspace-memberships/{membership_id}/",
|
f"/api/workspace-memberships/{membership_id}/",
|
||||||
{"is_active": False},
|
{"is_active": False},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert deactivate_response.status_code == 200
|
self.assertEqual(deactivate_response.status_code, 200)
|
||||||
|
|
||||||
remove_response = api_client.delete(f"/api/workspace-memberships/{membership_id}/")
|
remove_response = self.client.delete(
|
||||||
assert remove_response.status_code == 204
|
f"/api/workspace-memberships/{membership_id}/"
|
||||||
|
)
|
||||||
|
self.assertEqual(remove_response.status_code, 204)
|
||||||
|
|
||||||
notifications = _notifications_for(member)
|
notifications = self._notifications_for(self.member)
|
||||||
assert [item["type"] for item in notifications] == [
|
self.assertEqual(
|
||||||
|
[item["type"] for item in notifications],
|
||||||
|
[
|
||||||
"workspace_membership_removed",
|
"workspace_membership_removed",
|
||||||
"workspace_membership_deactivated",
|
"workspace_membership_deactivated",
|
||||||
"workspace_membership_role_changed",
|
"workspace_membership_role_changed",
|
||||||
"workspace_membership_added",
|
"workspace_membership_added",
|
||||||
]
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_workspace_membership_update_skips_self_notifications(self):
|
||||||
def test_workspace_membership_update_skips_self_notifications(
|
workspace = Workspace.objects.create(name="Product", description="", owner=self.owner)
|
||||||
fake_redis, api_client, owner
|
|
||||||
):
|
|
||||||
workspace = Workspace.objects.create(name="Product", description="", owner=owner)
|
|
||||||
owner_membership = WorkspaceMembership.objects.get(
|
owner_membership = WorkspaceMembership.objects.get(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user=owner,
|
user=self.owner,
|
||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
api_client.force_authenticate(user=owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
response = api_client.patch(
|
response = self.client.patch(
|
||||||
f"/api/workspace-memberships/{owner_membership.id}/",
|
f"/api/workspace-memberships/{owner_membership.id}/",
|
||||||
{"role": WorkspaceMembership.Role.OWNER},
|
{"role": WorkspaceMembership.Role.OWNER},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
self.assertEqual(response.status_code, 403)
|
||||||
assert _notifications_for(owner) == []
|
self.assertEqual(self._notifications_for(self.owner), [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,146 +1,22 @@
|
|||||||
import json
|
from django.conf import settings
|
||||||
from collections import defaultdict
|
from django.test import TestCase
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from apps.notifications.services import store as services
|
from apps.notifications.services import store as services
|
||||||
from apps.notifications.services import RedisNotificationStore
|
from apps.notifications.services import RedisNotificationStore
|
||||||
|
from apps.notifications.tests.fakes import FakeRedis
|
||||||
|
|
||||||
|
|
||||||
class FakePipeline:
|
class RedisNotificationStoreTests(TestCase):
|
||||||
def __init__(self, client):
|
def setUp(self):
|
||||||
self.client = client
|
self.fake_redis = FakeRedis()
|
||||||
self.operations = []
|
self.original_redis_client = services.redis_client
|
||||||
|
services.redis_client = self.fake_redis
|
||||||
|
|
||||||
def __getattr__(self, name):
|
def tearDown(self):
|
||||||
def wrapper(*args, **kwargs):
|
services.redis_client = self.original_redis_client
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
def test_add_publishes_notification_and_unread_count(self):
|
||||||
|
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||||
notification = RedisNotificationStore.add(
|
notification = RedisNotificationStore.add(
|
||||||
"user-1",
|
"user-1",
|
||||||
{
|
{
|
||||||
@@ -150,40 +26,42 @@ def test_add_publishes_notification_and_unread_count(fake_redis, settings):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert notification["title"] == "Build finished"
|
self.assertEqual(notification["title"], "Build finished")
|
||||||
assert notification["message"] == "Your deploy completed."
|
self.assertEqual(notification["message"], "Your deploy completed.")
|
||||||
assert notification["level"] == "success"
|
self.assertEqual(notification["level"], "success")
|
||||||
assert len(fake_redis.published) == 2
|
self.assertEqual(len(self.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
|
|
||||||
|
|
||||||
|
channel, payload = self.fake_redis.published[0]
|
||||||
|
self.assertEqual(
|
||||||
|
channel,
|
||||||
|
f"{settings.NOTIFICATION_REDIS_CHANNEL_PREFIX}:user-1",
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["event"], "notification")
|
||||||
|
self.assertEqual(payload["data"]["notification"]["id"], notification["id"])
|
||||||
|
self.assertEqual(payload["data"]["unread_count"], 1)
|
||||||
|
|
||||||
def test_mark_seen_and_mark_all_seen_publish_sync_events(fake_redis, settings):
|
def test_mark_seen_and_mark_all_seen_publish_sync_events(self):
|
||||||
settings.NOTIFICATIONS_ENABLED = True
|
with self.settings(NOTIFICATIONS_ENABLED=True):
|
||||||
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
first = RedisNotificationStore.add("user-2", {"title": "First"})
|
||||||
second = RedisNotificationStore.add("user-2", {"title": "Second"})
|
RedisNotificationStore.add("user-2", {"title": "Second"})
|
||||||
fake_redis.published.clear()
|
self.fake_redis.published.clear()
|
||||||
|
|
||||||
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
|
payload = RedisNotificationStore.mark_seen("user-2", first["id"])
|
||||||
|
|
||||||
assert payload["notification_id"] == first["id"]
|
self.assertEqual(payload["notification_id"], first["id"])
|
||||||
assert payload["deleted"] is False
|
self.assertFalse(payload["deleted"])
|
||||||
assert payload["notification"]["is_seen"] is True
|
self.assertTrue(payload["notification"]["is_seen"])
|
||||||
assert fake_redis.published[0][1]["event"] == "notification_seen"
|
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_seen")
|
||||||
|
|
||||||
fake_redis.published.clear()
|
self.fake_redis.published.clear()
|
||||||
updated = RedisNotificationStore.mark_all_seen("user-2")
|
updated = RedisNotificationStore.mark_all_seen("user-2")
|
||||||
|
|
||||||
assert updated == 2
|
self.assertEqual(updated, 2)
|
||||||
assert fake_redis.published[0][1]["event"] == "notification_mark_all_read"
|
self.assertEqual(self.fake_redis.published[0][1]["event"], "notification_mark_all_read")
|
||||||
assert fake_redis.published[1][1]["event"] == "unread_count"
|
self.assertEqual(self.fake_redis.published[1][1]["event"], "unread_count")
|
||||||
assert fake_redis.published[1][1]["data"]["unread_count"] == 0
|
self.assertEqual(self.fake_redis.published[1][1]["data"]["unread_count"], 0)
|
||||||
|
|
||||||
|
def test_list_returns_total_count_and_filtered_notifications(self):
|
||||||
def test_list_returns_total_count_and_filtered_notifications(fake_redis):
|
|
||||||
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
|
RedisNotificationStore.add("user-3", {"title": "General", "type": "general"})
|
||||||
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
|
RedisNotificationStore.add("user-3", {"title": "Billing", "type": "billing"})
|
||||||
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
|
RedisNotificationStore.add("user-3", {"title": "General 2", "type": "general"})
|
||||||
@@ -195,6 +73,6 @@ def test_list_returns_total_count_and_filtered_notifications(fake_redis):
|
|||||||
type_filter="general",
|
type_filter="general",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert total_count == 2
|
self.assertEqual(total_count, 2)
|
||||||
assert len(notifications) == 1
|
self.assertEqual(len(notifications), 1)
|
||||||
assert notifications[0]["type"] == "general"
|
self.assertEqual(notifications[0]["type"], "general")
|
||||||
|
|||||||
@@ -1,35 +1,37 @@
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
from django.test import override_settings
|
||||||
from django.utils import timezone
|
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.api import views
|
||||||
from apps.notifications.services import store as services
|
from apps.notifications.services import store as services
|
||||||
from apps.notifications.services import RedisNotificationStore
|
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
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class NotificationViewTests(APITestCase):
|
||||||
def fake_redis(monkeypatch):
|
@classmethod
|
||||||
redis = FakeRedis()
|
def setUpTestData(cls):
|
||||||
monkeypatch.setattr(services, "redis_client", redis)
|
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
|
||||||
return redis
|
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 tearDown(self):
|
||||||
def user(db):
|
services.redis_client = self.original_redis_client
|
||||||
return User.objects.create_user(mobile="09121111111", password="secret123")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def second_user(db):
|
|
||||||
return User.objects.create_user(mobile="09122222222", password="secret123")
|
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _read_sse_chunks(response, count):
|
def _read_sse_chunks(response, count):
|
||||||
iterator = iter(response.streaming_content)
|
iterator = iter(response.streaming_content)
|
||||||
chunks = []
|
chunks = []
|
||||||
@@ -41,67 +43,61 @@ def _read_sse_chunks(response, count):
|
|||||||
response.close()
|
response.close()
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _parse_sse_data(chunk: str) -> dict:
|
def _parse_sse_data(chunk):
|
||||||
for line in chunk.splitlines():
|
for line in chunk.splitlines():
|
||||||
if line.startswith("data: "):
|
if line.startswith("data: "):
|
||||||
return json.loads(line.removeprefix("data: "))
|
return json.loads(line.removeprefix("data: "))
|
||||||
raise AssertionError("SSE payload did not include 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 test_stream_token_endpoint_returns_short_lived_token(user):
|
response = self.client.post("/api/notifications/stream-token/")
|
||||||
client = APIClient()
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
response = client.post("/api/notifications/stream-token/")
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.data["token"])
|
||||||
|
self.assertGreater(response.data["expires_in"], 0)
|
||||||
|
|
||||||
assert response.status_code == 200
|
def test_stream_endpoint_rejects_missing_and_expired_token(self):
|
||||||
assert response.data["token"]
|
missing = self.client.get("/api/notifications/stream/")
|
||||||
assert response.data["expires_in"] > 0
|
self.assertEqual(missing.status_code, 401)
|
||||||
|
|
||||||
|
with override_settings(NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS=1):
|
||||||
def test_stream_endpoint_rejects_missing_and_expired_token(user, settings):
|
token = views._issue_stream_token_for_user(str(self.user.id))
|
||||||
client = APIClient()
|
|
||||||
|
|
||||||
missing = client.get("/api/notifications/stream/")
|
|
||||||
assert missing.status_code == 401
|
|
||||||
|
|
||||||
settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS = 1
|
|
||||||
token = views._issue_stream_token_for_user(str(user.id))
|
|
||||||
time.sleep(1.1)
|
time.sleep(1.1)
|
||||||
|
expired = self.client.get(f"/api/notifications/stream/?token={token}")
|
||||||
|
|
||||||
expired = client.get(f"/api/notifications/stream/?token={token}")
|
self.assertEqual(expired.status_code, 401)
|
||||||
assert expired.status_code == 401
|
|
||||||
|
|
||||||
|
def test_stream_endpoint_sends_only_current_users_notifications(self):
|
||||||
def test_stream_endpoint_sends_only_current_users_notifications(
|
RedisNotificationStore.add(str(self.user.id), {"title": "For current user"})
|
||||||
fake_redis, user, second_user, monkeypatch
|
RedisNotificationStore.add(str(self.second_user.id), {"title": "For another user"})
|
||||||
):
|
|
||||||
RedisNotificationStore.add(str(user.id), {"title": "For current user"})
|
|
||||||
RedisNotificationStore.add(str(second_user.id), {"title": "For another user"})
|
|
||||||
pubsub = FakePubSub()
|
pubsub = FakePubSub()
|
||||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
|
||||||
token = views._issue_stream_token_for_user(str(user.id))
|
|
||||||
|
|
||||||
client = APIClient()
|
with patch.object(
|
||||||
response = client.get(
|
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}",
|
f"/api/notifications/stream/?token={token}",
|
||||||
HTTP_ACCEPT="text/event-stream",
|
HTTP_ACCEPT="text/event-stream",
|
||||||
)
|
)
|
||||||
retry_line, connected_chunk = _read_sse_chunks(response, 2)
|
retry_line, connected_chunk = self._read_sse_chunks(response, 2)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert retry_line.startswith("retry:")
|
self.assertTrue(retry_line.startswith("retry:"))
|
||||||
connected = _parse_sse_data(connected_chunk)
|
connected = self._parse_sse_data(connected_chunk)
|
||||||
assert connected["unread_count"] == 1
|
self.assertEqual(connected["unread_count"], 1)
|
||||||
assert [item["title"] for item in connected["notifications"]] == ["For current user"]
|
self.assertEqual(
|
||||||
|
[item["title"] for item in connected["notifications"]],
|
||||||
|
["For current user"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stream_endpoint_emits_heartbeat(self):
|
||||||
def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch):
|
|
||||||
pubsub = FakePubSub()
|
pubsub = FakePubSub()
|
||||||
monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub))
|
|
||||||
settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 1
|
|
||||||
|
|
||||||
first_now = timezone.now()
|
first_now = timezone.now()
|
||||||
tick_values = iter(
|
tick_values = iter(
|
||||||
[
|
[
|
||||||
@@ -118,49 +114,55 @@ def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch
|
|||||||
def fake_now():
|
def fake_now():
|
||||||
return next(tick_values, last_tick)
|
return next(tick_values, last_tick)
|
||||||
|
|
||||||
monkeypatch.setattr(views.timezone, "now", fake_now)
|
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()
|
view = views.NotificationStreamView()
|
||||||
stream = view._build_stream(str(user.id))
|
stream = view._build_stream(str(self.user.id))
|
||||||
|
|
||||||
chunks = [next(stream) for _ in range(4)]
|
chunks = [next(stream) for _ in range(4)]
|
||||||
stream.close()
|
stream.close()
|
||||||
|
|
||||||
assert "event: ping" in chunks[3]
|
self.assertIn("event: ping", chunks[3])
|
||||||
|
|
||||||
|
def test_notification_list_and_seen_endpoints_work(self):
|
||||||
def test_notification_list_and_seen_endpoints_work(fake_redis, user):
|
|
||||||
notification = RedisNotificationStore.add(
|
notification = RedisNotificationStore.add(
|
||||||
str(user.id),
|
str(self.user.id),
|
||||||
{"title": "Deploy succeeded", "type": "deploy"},
|
{"title": "Deploy succeeded", "type": "deploy"},
|
||||||
)
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
client = APIClient()
|
list_response = self.client.get("/api/notifications/list/?type=deploy")
|
||||||
client.force_authenticate(user=user)
|
self.assertEqual(list_response.status_code, 200)
|
||||||
|
self.assertEqual(list_response.data["count"], 1)
|
||||||
list_response = client.get("/api/notifications/list/?type=deploy")
|
self.assertEqual(list_response.data["unread_count"], 1)
|
||||||
assert list_response.status_code == 200
|
self.assertEqual(
|
||||||
assert list_response.data["count"] == 1
|
list_response.data["notifications"][0]["title"],
|
||||||
assert list_response.data["unread_count"] == 1
|
"Deploy succeeded",
|
||||||
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()
|
seen_response = self.client.post(
|
||||||
client.force_authenticate(user=user)
|
"/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"])
|
||||||
|
|
||||||
response = client.delete(f"/api/notifications/{notification['id']}/")
|
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)
|
||||||
|
|
||||||
assert response.status_code == 200
|
response = self.client.delete(f"/api/notifications/{notification['id']}/")
|
||||||
assert response.data["deleted"] is True
|
|
||||||
assert response.data["notification_id"] == notification["id"]
|
self.assertEqual(response.status_code, 200)
|
||||||
assert RedisNotificationStore.get(str(user.id), notification["id"]) is None
|
self.assertTrue(response.data["deleted"])
|
||||||
|
self.assertEqual(response.data["notification_id"], notification["id"])
|
||||||
|
self.assertIsNone(RedisNotificationStore.get(str(self.user.id), notification["id"]))
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import pytest
|
from rest_framework.test import APITestCase
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
@@ -7,69 +6,65 @@ from apps.users.models import User
|
|||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class ProjectViewTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(
|
||||||
|
mobile="09121110001",
|
||||||
@pytest.fixture()
|
password="secret123",
|
||||||
def owner(db):
|
first_name="Owner",
|
||||||
return User.objects.create_user(mobile="09121110001", password="secret123", first_name="Owner")
|
)
|
||||||
|
cls.workspace = Workspace.objects.create(name="Projects", owner=cls.owner)
|
||||||
|
cls.member = User.objects.create_user(
|
||||||
@pytest.fixture()
|
mobile="09121110002",
|
||||||
def workspace(owner):
|
password="secret123",
|
||||||
return Workspace.objects.create(name="Projects", owner=owner)
|
first_name="Member",
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def member(db, workspace):
|
|
||||||
user = User.objects.create_user(mobile="09121110002", password="secret123", first_name="Member")
|
|
||||||
WorkspaceMembership.objects.create(
|
WorkspaceMembership.objects.create(
|
||||||
workspace=workspace,
|
workspace=cls.workspace,
|
||||||
user=user,
|
user=cls.member,
|
||||||
role=WorkspaceMembership.Role.MEMBER,
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
return user
|
cls.first_client = Client.objects.create(workspace=cls.workspace, name="Acme")
|
||||||
|
cls.second_client = Client.objects.create(workspace=cls.workspace, name="Globex")
|
||||||
|
cls.third_client = Client.objects.create(workspace=cls.workspace, name="Initech")
|
||||||
|
Project.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
client=cls.first_client,
|
||||||
|
name="Alpha",
|
||||||
|
)
|
||||||
|
Project.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
client=cls.second_client,
|
||||||
|
name="Beta",
|
||||||
|
)
|
||||||
|
Project.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
client=cls.third_client,
|
||||||
|
name="Gamma",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_project_list_supports_multi_client_filter(self):
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
@pytest.fixture()
|
response = self.client.get(
|
||||||
def clients(workspace):
|
|
||||||
first = Client.objects.create(workspace=workspace, name="Acme")
|
|
||||||
second = Client.objects.create(workspace=workspace, name="Globex")
|
|
||||||
third = Client.objects.create(workspace=workspace, name="Initech")
|
|
||||||
return first, second, third
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def projects(workspace, clients):
|
|
||||||
first, second, third = clients
|
|
||||||
return [
|
|
||||||
Project.objects.create(workspace=workspace, client=first, name="Alpha"),
|
|
||||||
Project.objects.create(workspace=workspace, client=second, name="Beta"),
|
|
||||||
Project.objects.create(workspace=workspace, client=third, name="Gamma"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_project_list_supports_multi_client_filter(api_client, member, workspace, clients, projects):
|
|
||||||
api_client.force_authenticate(user=member)
|
|
||||||
first, second, _ = clients
|
|
||||||
|
|
||||||
response = api_client.get(
|
|
||||||
"/api/projects/",
|
"/api/projects/",
|
||||||
[
|
[
|
||||||
("workspace", str(workspace.id)),
|
("workspace", str(self.workspace.id)),
|
||||||
("clients", str(first.id)),
|
("clients", str(self.first_client.id)),
|
||||||
("clients", str(second.id)),
|
("clients", str(self.second_client.id)),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
items = (
|
items = (
|
||||||
response.data
|
response.data
|
||||||
if isinstance(response.data, list)
|
if isinstance(response.data, list)
|
||||||
else response.data.get("results") or response.data.get("items", [])
|
else response.data.get("results") or response.data.get("items", [])
|
||||||
)
|
)
|
||||||
result_ids = {str(item["client"]["id"]) for item in items}
|
result_ids = {str(item["client"]["id"]) for item in items}
|
||||||
assert result_ids == {str(first.id), str(second.id)}
|
self.assertEqual(
|
||||||
|
result_ids,
|
||||||
|
{str(self.first_client.id), str(self.second_client.id)},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,126 +1,41 @@
|
|||||||
from datetime import timedelta
|
from unittest.mock import patch
|
||||||
from decimal import Decimal
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from openpyxl import load_workbook
|
|
||||||
|
|
||||||
from apps.notifications.services import store as notification_store
|
|
||||||
from apps.reports.models import ReportExportJob
|
from apps.reports.models import ReportExportJob
|
||||||
from apps.reports.tasks import cleanup_expired_report_exports_task, generate_report_export_task
|
from apps.reports.tasks import (
|
||||||
from apps.time_entries.models import TimeEntry
|
cleanup_expired_report_exports_task,
|
||||||
|
generate_report_export_task,
|
||||||
|
)
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
|
|
||||||
class FakeRedis:
|
class ReportTaskTests(TestCase):
|
||||||
def pipeline(self):
|
@classmethod
|
||||||
return self
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(
|
||||||
def zadd(self, *args, **kwargs):
|
mobile="09129990001",
|
||||||
return self
|
password="secret123",
|
||||||
|
first_name="Owner",
|
||||||
def hset(self, *args, **kwargs):
|
last_name="User",
|
||||||
return self
|
|
||||||
|
|
||||||
def sadd(self, *args, **kwargs):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def execute(self):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def publish(self, *args, **kwargs):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def zrevrange(self, *args, **kwargs):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def hget(self, *args, **kwargs):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def zrem(self, *args, **kwargs):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def hdel(self, *args, **kwargs):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def zcard(self, *args, **kwargs):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def smembers(self, *args, **kwargs):
|
|
||||||
return set()
|
|
||||||
|
|
||||||
def srem(self, *args, **kwargs):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def fake_redis(monkeypatch):
|
|
||||||
redis = FakeRedis()
|
|
||||||
monkeypatch.setattr(notification_store, "redis_client", redis)
|
|
||||||
return redis
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def owner(db):
|
|
||||||
return User.objects.create_user(mobile="09129990001", password="secret123", first_name="Owner", last_name="User")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def teammate(db):
|
|
||||||
return User.objects.create_user(mobile="09129990002", password="secret123", first_name="Team", last_name="Mate")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def workspace(owner, teammate):
|
|
||||||
workspace = Workspace.objects.create(name="Exports", owner=owner)
|
|
||||||
workspace.memberships.create(user=teammate, role="member", is_active=True)
|
|
||||||
return workspace
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def time_entry(workspace, owner):
|
|
||||||
return TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=owner,
|
|
||||||
description="Export row",
|
|
||||||
start_time="2026-04-12T08:00:00+03:30",
|
|
||||||
end_time="2026-04-12T10:00:00+03:30",
|
|
||||||
duration=timedelta(hours=2),
|
|
||||||
is_billable=True,
|
|
||||||
hourly_rate=Decimal("15.00"),
|
|
||||||
currency="USD",
|
|
||||||
)
|
)
|
||||||
|
cls.workspace = Workspace.objects.create(name="Exports", owner=cls.owner)
|
||||||
|
|
||||||
|
def test_generate_excel_export_marks_job_complete_and_sends_notification(self):
|
||||||
@pytest.fixture()
|
|
||||||
def teammate_entry(workspace, teammate):
|
|
||||||
return TimeEntry.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=teammate,
|
|
||||||
description="Team row",
|
|
||||||
start_time="2026-04-13T08:00:00+03:30",
|
|
||||||
end_time="2026-04-13T09:00:00+03:30",
|
|
||||||
duration=timedelta(hours=1),
|
|
||||||
is_billable=False,
|
|
||||||
currency="USD",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_export_creates_file_and_marks_job_complete(fake_redis, workspace, owner, time_entry):
|
|
||||||
job = ReportExportJob.objects.create(
|
job = ReportExportJob.objects.create(
|
||||||
requesting_user=owner,
|
requesting_user=self.owner,
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
export_type=ReportExportJob.ExportType.EXCEL,
|
export_type=ReportExportJob.ExportType.EXCEL,
|
||||||
filters={
|
filters={
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(self.workspace.id),
|
||||||
"period": "this_month",
|
"period": "this_month",
|
||||||
"from_date": "2026-04-01",
|
"from_date": "2026-04-01",
|
||||||
"to_date": "2026-04-30",
|
"to_date": "2026-04-30",
|
||||||
"user": str(owner.id),
|
"user": str(self.owner.id),
|
||||||
"client": None,
|
"client": None,
|
||||||
"project": None,
|
"project": None,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
@@ -128,107 +43,34 @@ def test_generate_export_creates_file_and_marks_job_complete(fake_redis, workspa
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}) as build_table_report:
|
||||||
|
with patch("apps.reports.tasks.build_user_scoped_table_reports", return_value=[]) as build_user_reports:
|
||||||
|
with patch("apps.reports.tasks.build_excel_report", return_value=b"excel-content") as build_excel_report:
|
||||||
|
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
|
||||||
generate_report_export_task(str(job.id))
|
generate_report_export_task(str(job.id))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.status, ReportExportJob.Status.COMPLETED)
|
||||||
|
self.assertTrue(bool(job.file))
|
||||||
|
self.assertTrue(default_storage.exists(job.file.name))
|
||||||
|
build_table_report.assert_called_once()
|
||||||
|
build_user_reports.assert_called_once()
|
||||||
|
build_excel_report.assert_called_once()
|
||||||
|
notify.assert_called_once()
|
||||||
|
self.assertEqual(notify.call_args.args[0], str(self.owner.id))
|
||||||
|
self.assertEqual(notify.call_args.args[1]["type"], "report_export_ready")
|
||||||
|
|
||||||
assert job.status == ReportExportJob.Status.COMPLETED
|
def test_generate_pdf_export_failure_marks_job_failed_and_notifies(self):
|
||||||
assert bool(job.file)
|
|
||||||
assert default_storage.exists(job.file.name)
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope(
|
|
||||||
fake_redis,
|
|
||||||
workspace,
|
|
||||||
owner,
|
|
||||||
teammate,
|
|
||||||
time_entry,
|
|
||||||
teammate_entry,
|
|
||||||
):
|
|
||||||
job = ReportExportJob.objects.create(
|
job = ReportExportJob.objects.create(
|
||||||
requesting_user=owner,
|
requesting_user=self.owner,
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
export_type=ReportExportJob.ExportType.EXCEL,
|
|
||||||
filters={
|
|
||||||
"workspace": str(workspace.id),
|
|
||||||
"period": "this_month",
|
|
||||||
"from_date": "2026-04-01",
|
|
||||||
"to_date": "2026-04-30",
|
|
||||||
"user": None,
|
|
||||||
"client": None,
|
|
||||||
"project": None,
|
|
||||||
"tags": [],
|
|
||||||
"language": "en",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
generate_report_export_task(str(job.id))
|
|
||||||
job.refresh_from_db()
|
|
||||||
|
|
||||||
workbook = load_workbook(BytesIO(job.file.read()))
|
|
||||||
assert workbook.sheetnames[0] == "Overall Report"
|
|
||||||
assert any("Owner User" in sheet for sheet in workbook.sheetnames[1:])
|
|
||||||
assert any("Team Mate" in sheet for sheet in workbook.sheetnames[1:])
|
|
||||||
assert len(workbook.sheetnames) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_excel_export_includes_daily_rate_column_and_split_user_meta(
|
|
||||||
fake_redis,
|
|
||||||
workspace,
|
|
||||||
owner,
|
|
||||||
time_entry,
|
|
||||||
):
|
|
||||||
job = ReportExportJob.objects.create(
|
|
||||||
requesting_user=owner,
|
|
||||||
workspace=workspace,
|
|
||||||
export_type=ReportExportJob.ExportType.EXCEL,
|
|
||||||
filters={
|
|
||||||
"workspace": str(workspace.id),
|
|
||||||
"period": "this_month",
|
|
||||||
"from_date": "2026-04-01",
|
|
||||||
"to_date": "2026-04-30",
|
|
||||||
"user": str(owner.id),
|
|
||||||
"client": None,
|
|
||||||
"project": None,
|
|
||||||
"tags": [],
|
|
||||||
"language": "en",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
generate_report_export_task(str(job.id))
|
|
||||||
job.refresh_from_db()
|
|
||||||
|
|
||||||
workbook = load_workbook(BytesIO(job.file.read()))
|
|
||||||
worksheet = workbook.active
|
|
||||||
values = list(worksheet.iter_rows(values_only=True))
|
|
||||||
|
|
||||||
assert any(row[:2] == ("User", "Owner User") for row in values if row)
|
|
||||||
assert any(row[:2] == ("Mobile", "09129990001") for row in values if row)
|
|
||||||
|
|
||||||
daily_header = next(row[:6] for row in values if row and row[0] == "Date")
|
|
||||||
assert daily_header == (
|
|
||||||
"Date",
|
|
||||||
"Billable hours",
|
|
||||||
"Non-billable hours",
|
|
||||||
"Total hours",
|
|
||||||
"Hourly rate",
|
|
||||||
"Income",
|
|
||||||
)
|
|
||||||
|
|
||||||
daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12")
|
|
||||||
assert daily_row[4] == "15 USD"
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry):
|
|
||||||
job = ReportExportJob.objects.create(
|
|
||||||
requesting_user=owner,
|
|
||||||
workspace=workspace,
|
|
||||||
export_type=ReportExportJob.ExportType.PDF,
|
export_type=ReportExportJob.ExportType.PDF,
|
||||||
filters={
|
filters={
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(self.workspace.id),
|
||||||
"period": "this_month",
|
"period": "this_month",
|
||||||
"from_date": "2026-04-01",
|
"from_date": "2026-04-01",
|
||||||
"to_date": "2026-04-30",
|
"to_date": "2026-04-30",
|
||||||
"user": str(owner.id),
|
"user": str(self.owner.id),
|
||||||
"client": None,
|
"client": None,
|
||||||
"project": None,
|
"project": None,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
@@ -236,17 +78,22 @@ def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owne
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}):
|
||||||
|
with patch("apps.reports.tasks.build_pdf_report", side_effect=RuntimeError("boom")):
|
||||||
|
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
generate_report_export_task(str(job.id))
|
generate_report_export_task(str(job.id))
|
||||||
|
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.status, ReportExportJob.Status.FAILED)
|
||||||
|
self.assertEqual(job.error_message, "boom")
|
||||||
|
notify.assert_called_once()
|
||||||
|
self.assertEqual(notify.call_args.args[1]["type"], "report_export_failed")
|
||||||
|
|
||||||
assert job.status == ReportExportJob.Status.COMPLETED
|
def test_cleanup_expires_and_removes_files(self):
|
||||||
assert job.file.read(4) == b"%PDF"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
|
|
||||||
job = ReportExportJob.objects.create(
|
job = ReportExportJob.objects.create(
|
||||||
requesting_user=owner,
|
requesting_user=self.owner,
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
export_type=ReportExportJob.ExportType.EXCEL,
|
export_type=ReportExportJob.ExportType.EXCEL,
|
||||||
status=ReportExportJob.Status.COMPLETED,
|
status=ReportExportJob.Status.COMPLETED,
|
||||||
filters={},
|
filters={},
|
||||||
@@ -259,6 +106,6 @@ def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
|
|||||||
removed = cleanup_expired_report_exports_task()
|
removed = cleanup_expired_report_exports_task()
|
||||||
job.refresh_from_db()
|
job.refresh_from_db()
|
||||||
|
|
||||||
assert removed == 1
|
self.assertEqual(removed, 1)
|
||||||
assert job.status == ReportExportJob.Status.EXPIRED
|
self.assertEqual(job.status, ReportExportJob.Status.EXPIRED)
|
||||||
assert not default_storage.exists(file_name)
|
self.assertFalse(default_storage.exists(file_name))
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
from rest_framework.test import APITestCase
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
@@ -12,55 +12,53 @@ from apps.users.models import User
|
|||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class ReportViewTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(
|
||||||
|
mobile="09128880001",
|
||||||
|
password="secret123",
|
||||||
|
first_name="Owner",
|
||||||
|
)
|
||||||
|
cls.admin = User.objects.create_user(
|
||||||
|
mobile="09128880002",
|
||||||
|
password="secret123",
|
||||||
|
first_name="Admin",
|
||||||
|
)
|
||||||
|
cls.member = User.objects.create_user(
|
||||||
|
mobile="09128880003",
|
||||||
|
password="secret123",
|
||||||
|
first_name="Member",
|
||||||
|
)
|
||||||
|
cls.workspace = Workspace.objects.create(name="Reports", owner=cls.owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.admin,
|
||||||
|
role=WorkspaceMembership.Role.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
cls.client_obj = Client.objects.create(workspace=cls.workspace, name="Acme")
|
||||||
|
cls.project = Project.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
name="Website",
|
||||||
|
client=cls.client_obj,
|
||||||
|
)
|
||||||
|
cls.tag = Tag.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
name="Design",
|
||||||
|
color="#ffffff",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def owner(db):
|
|
||||||
return User.objects.create_user(mobile="09128880001", password="secret123", first_name="Owner")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def admin(db):
|
|
||||||
return User.objects.create_user(mobile="09128880002", password="secret123", first_name="Admin")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def member(db):
|
|
||||||
return User.objects.create_user(mobile="09128880003", password="secret123", first_name="Member")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def workspace(owner, admin, member):
|
|
||||||
workspace = Workspace.objects.create(name="Reports", owner=owner)
|
|
||||||
WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True)
|
|
||||||
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True)
|
|
||||||
return workspace
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def client(workspace):
|
|
||||||
return Client.objects.create(workspace=workspace, name="Acme")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def project(workspace, client):
|
|
||||||
return Project.objects.create(workspace=workspace, name="Website", client=client)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tag(workspace):
|
|
||||||
return Tag.objects.create(workspace=workspace, name="Design", color="#ffffff")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def time_entries(workspace, owner, member, project, tag):
|
|
||||||
entry_owner = TimeEntry.objects.create(
|
entry_owner = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=cls.workspace,
|
||||||
user=owner,
|
user=cls.owner,
|
||||||
project=project,
|
project=cls.project,
|
||||||
description="Owner work",
|
description="Owner work",
|
||||||
start_time="2026-04-10T08:00:00+03:30",
|
start_time="2026-04-10T08:00:00+03:30",
|
||||||
end_time="2026-04-10T10:00:00+03:30",
|
end_time="2026-04-10T10:00:00+03:30",
|
||||||
@@ -69,11 +67,12 @@ def time_entries(workspace, owner, member, project, tag):
|
|||||||
hourly_rate=Decimal("25.00"),
|
hourly_rate=Decimal("25.00"),
|
||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
entry_owner.tags.add(tag)
|
entry_owner.tags.add(cls.tag)
|
||||||
|
|
||||||
entry_member = TimeEntry.objects.create(
|
entry_member = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=cls.workspace,
|
||||||
user=member,
|
user=cls.member,
|
||||||
project=project,
|
project=cls.project,
|
||||||
description="Member work",
|
description="Member work",
|
||||||
start_time="2026-04-11T09:00:00+03:30",
|
start_time="2026-04-11T09:00:00+03:30",
|
||||||
end_time="2026-04-11T10:00:00+03:30",
|
end_time="2026-04-11T10:00:00+03:30",
|
||||||
@@ -81,44 +80,40 @@ def time_entries(workspace, owner, member, project, tag):
|
|||||||
is_billable=False,
|
is_billable=False,
|
||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
entry_member.tags.add(tag)
|
entry_member.tags.add(cls.tag)
|
||||||
return [entry_owner, entry_member]
|
|
||||||
|
|
||||||
|
def test_member_only_sees_own_chart_report(self):
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
def test_member_only_sees_own_chart_report(api_client, member, workspace, time_entries):
|
response = self.client.get(
|
||||||
api_client.force_authenticate(user=member)
|
|
||||||
|
|
||||||
response = api_client.get(
|
|
||||||
"/api/reports/chart/",
|
"/api/reports/chart/",
|
||||||
{"workspace": str(workspace.id), "period": "this_month"},
|
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["summary"]["total_duration"] == "01:00:00"
|
self.assertEqual(response.data["summary"]["total_duration"], "01:00:00")
|
||||||
|
|
||||||
|
def test_admin_can_request_combined_table_report(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
def test_admin_can_request_combined_table_report(api_client, admin, workspace, time_entries):
|
response = self.client.get(
|
||||||
api_client.force_authenticate(user=admin)
|
|
||||||
|
|
||||||
response = api_client.get(
|
|
||||||
"/api/reports/table/",
|
"/api/reports/table/",
|
||||||
{"workspace": str(workspace.id), "period": "this_month"},
|
{"workspace": str(self.workspace.id), "period": "this_month"},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["summary"]["total_duration"] == "03:00:00"
|
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
|
||||||
assert len(response.data["days"]) == 2
|
self.assertEqual(len(response.data["days"]), 2)
|
||||||
assert response.data["days"][0]["latest_hourly_rate"] is None
|
self.assertIsNone(response.data["days"][0]["latest_hourly_rate"])
|
||||||
assert response.data["days"][1]["latest_hourly_rate"] is None
|
self.assertIsNone(response.data["days"][1]["latest_hourly_rate"])
|
||||||
|
|
||||||
|
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
|
||||||
def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, workspace, project):
|
self.client.force_authenticate(user=self.owner)
|
||||||
api_client.force_authenticate(user=owner)
|
|
||||||
|
|
||||||
TimeEntry.objects.create(
|
TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
user=owner,
|
user=self.owner,
|
||||||
project=project,
|
project=self.project,
|
||||||
description="Morning work",
|
description="Morning work",
|
||||||
start_time="2026-04-15T08:00:00+03:30",
|
start_time="2026-04-15T08:00:00+03:30",
|
||||||
end_time="2026-04-15T09:00:00+03:30",
|
end_time="2026-04-15T09:00:00+03:30",
|
||||||
@@ -128,9 +123,9 @@ def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, works
|
|||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
TimeEntry.objects.create(
|
TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
user=owner,
|
user=self.owner,
|
||||||
project=project,
|
project=self.project,
|
||||||
description="Later work",
|
description="Later work",
|
||||||
start_time="2026-04-15T13:00:00+03:30",
|
start_time="2026-04-15T13:00:00+03:30",
|
||||||
end_time="2026-04-15T15:00:00+03:30",
|
end_time="2026-04-15T15:00:00+03:30",
|
||||||
@@ -140,42 +135,48 @@ def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, works
|
|||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = api_client.get(
|
response = self.client.get(
|
||||||
"/api/reports/table/",
|
"/api/reports/table/",
|
||||||
{"workspace": str(workspace.id), "period": "this_month", "user": str(owner.id)},
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["days"][0]["latest_hourly_rate"] == {
|
target_day = next(day for day in response.data["days"] if day["date"] == "2026-04-15")
|
||||||
"amount": "35.00",
|
self.assertEqual(
|
||||||
"currency": "USD",
|
target_day["latest_hourly_rate"],
|
||||||
}
|
{"amount": "35.00", "currency": "USD"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_custom_period_longer_than_31_days_is_rejected(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):
|
response = self.client.get(
|
||||||
api_client.force_authenticate(user=owner)
|
|
||||||
|
|
||||||
response = api_client.get(
|
|
||||||
"/api/reports/chart/",
|
"/api/reports/chart/",
|
||||||
{
|
{
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(self.workspace.id),
|
||||||
"period": "period",
|
"period": "period",
|
||||||
"from_date": "2026-01-01",
|
"from_date": "2026-01-01",
|
||||||
"to_date": "2026-02-15",
|
"to_date": "2026-02-15",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_persian_this_month_uses_jalali_month_bounds(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspace, project, monkeypatch):
|
with patch(
|
||||||
api_client.force_authenticate(user=owner)
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
monkeypatch.setattr("apps.reports.services.aggregation.timezone.localdate", lambda: date(2026, 4, 27))
|
return_value=date(2026, 4, 27),
|
||||||
|
):
|
||||||
TimeEntry.objects.create(
|
TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
user=owner,
|
user=self.owner,
|
||||||
project=project,
|
project=self.project,
|
||||||
description="Previous jalali month",
|
description="Previous jalali month",
|
||||||
start_time="2026-04-20T08:00:00+03:30",
|
start_time="2026-04-20T08:00:00+03:30",
|
||||||
end_time="2026-04-20T09:00:00+03:30",
|
end_time="2026-04-20T09:00:00+03:30",
|
||||||
@@ -184,9 +185,9 @@ def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspac
|
|||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
TimeEntry.objects.create(
|
TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
user=owner,
|
user=self.owner,
|
||||||
project=project,
|
project=self.project,
|
||||||
description="Current jalali month",
|
description="Current jalali month",
|
||||||
start_time="2026-04-21T08:00:00+03:30",
|
start_time="2026-04-21T08:00:00+03:30",
|
||||||
end_time="2026-04-21T10:00:00+03:30",
|
end_time="2026-04-21T10:00:00+03:30",
|
||||||
@@ -195,11 +196,15 @@ def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspac
|
|||||||
currency="USD",
|
currency="USD",
|
||||||
)
|
)
|
||||||
|
|
||||||
response = api_client.get(
|
response = self.client.get(
|
||||||
"/api/reports/table/",
|
"/api/reports/table/",
|
||||||
{"workspace": str(workspace.id), "period": "this_month", "language": "fa"},
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"language": "fa",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["summary"]["total_duration"] == "02:00:00"
|
self.assertEqual(response.data["summary"]["total_duration"], "02:00:00")
|
||||||
assert response.data["scope"]["from_date"] == "2026-04-21"
|
self.assertEqual(response.data["scope"]["from_date"], "2026-04-21")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
@@ -12,17 +14,34 @@ from apps.workspaces.models import Workspace
|
|||||||
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
return timezone.make_aware(datetime(year, month, day, hour, minute, second), timezone.get_current_timezone())
|
current_timezone = timezone.get_current_timezone()
|
||||||
|
return timezone.make_aware(
|
||||||
|
datetime(year, month, day, hour, minute, second),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_time_entry_filter_supports_project_client_tags_and_custom_dates(db):
|
class TimeEntryFilterTests(TestCase):
|
||||||
|
def test_time_entry_filter_supports_project_client_tags_and_custom_dates(self):
|
||||||
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
client_a = Client.objects.create(workspace=workspace, name="Client A")
|
client_a = Client.objects.create(workspace=workspace, name="Client A")
|
||||||
client_b = Client.objects.create(workspace=workspace, name="Client B")
|
client_b = Client.objects.create(workspace=workspace, name="Client B")
|
||||||
project_a = Project.objects.create(workspace=workspace, client=client_a, name="Project A")
|
project_a = Project.objects.create(
|
||||||
project_b = Project.objects.create(workspace=workspace, client=client_b, name="Project B")
|
workspace=workspace,
|
||||||
tag_backend = Tag.objects.create(workspace=workspace, name="Backend", color="#0EA5E9")
|
client=client_a,
|
||||||
|
name="Project A",
|
||||||
|
)
|
||||||
|
project_b = Project.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
client=client_b,
|
||||||
|
name="Project B",
|
||||||
|
)
|
||||||
|
tag_backend = Tag.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
name="Backend",
|
||||||
|
color="#0EA5E9",
|
||||||
|
)
|
||||||
tag_ops = Tag.objects.create(workspace=workspace, name="Ops", color="#10B981")
|
tag_ops = Tag.objects.create(workspace=workspace, name="Ops", color="#10B981")
|
||||||
|
|
||||||
entry_a = TimeEntry.objects.create(
|
entry_a = TimeEntry.objects.create(
|
||||||
@@ -59,10 +78,9 @@ def test_time_entry_filter_supports_project_client_tags_and_custom_dates(db):
|
|||||||
queryset=queryset,
|
queryset=queryset,
|
||||||
).qs
|
).qs
|
||||||
|
|
||||||
assert list(filtered) == [entry_a]
|
self.assertEqual(list(filtered), [entry_a])
|
||||||
|
|
||||||
|
def test_time_entry_filter_supports_status_values(self):
|
||||||
def test_time_entry_filter_supports_status_values(db):
|
|
||||||
user = User.objects.create_user(mobile="09125555555", password="secret123")
|
user = User.objects.create_user(mobile="09125555555", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
|
||||||
@@ -85,5 +103,5 @@ def test_time_entry_filter_supports_status_values(db):
|
|||||||
ended = TimeEntryFilter(data={"status": "ended"}, queryset=queryset).qs
|
ended = TimeEntryFilter(data={"status": "ended"}, queryset=queryset).qs
|
||||||
running = TimeEntryFilter(data={"status": "running"}, queryset=queryset).qs
|
running = TimeEntryFilter(data={"status": "running"}, queryset=queryset).qs
|
||||||
|
|
||||||
assert list(ended) == [ended_entry]
|
self.assertEqual(list(ended), [ended_entry])
|
||||||
assert list(running) == [running_entry]
|
self.assertEqual(list(running), [running_entry])
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.time_entries.api.serializers import TimeEntrySerializer
|
|
||||||
from apps.time_entries.models import TimeEntry
|
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
|
from apps.time_entries.api.serializers import TimeEntrySerializer
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
|
|
||||||
def test_time_entry_serializer_keeps_seconds(db):
|
class TimeEntrySerializerTests(TestCase):
|
||||||
|
def test_time_entry_serializer_keeps_seconds(self):
|
||||||
user = User.objects.create_user(mobile="09123333333", password="secret123")
|
user = User.objects.create_user(mobile="09123333333", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
current_timezone = timezone.get_current_timezone()
|
current_timezone = timezone.get_current_timezone()
|
||||||
|
|
||||||
start_time = timezone.make_aware(datetime(2026, 4, 23, 10, 15, 42), current_timezone)
|
start_time = timezone.make_aware(
|
||||||
end_time = timezone.make_aware(datetime(2026, 4, 23, 11, 0, 5), current_timezone)
|
datetime(2026, 4, 23, 10, 15, 42),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
end_time = timezone.make_aware(
|
||||||
|
datetime(2026, 4, 23, 11, 0, 5),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
entry = TimeEntry.objects.create(
|
entry = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
@@ -27,11 +35,10 @@ def test_time_entry_serializer_keeps_seconds(db):
|
|||||||
|
|
||||||
data = TimeEntrySerializer(entry).data
|
data = TimeEntrySerializer(entry).data
|
||||||
|
|
||||||
assert data["start_time"] == start_time.strftime("%Y-%m-%d %H:%M:%S")
|
self.assertEqual(data["start_time"], start_time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
assert data["end_time"] == end_time.strftime("%Y-%m-%d %H:%M:%S")
|
self.assertEqual(data["end_time"], end_time.strftime("%Y-%m-%d %H:%M:%S"))
|
||||||
|
|
||||||
|
def test_time_entry_serializer_includes_deleted_project_and_tags(self):
|
||||||
def test_time_entry_serializer_includes_deleted_project_and_tags(db):
|
|
||||||
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
user = User.objects.create_user(mobile="09124444444", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
project = Project.objects.create(workspace=workspace, name="Legacy Project")
|
project = Project.objects.create(workspace=workspace, name="Legacy Project")
|
||||||
@@ -51,9 +58,9 @@ def test_time_entry_serializer_includes_deleted_project_and_tags(db):
|
|||||||
|
|
||||||
data = TimeEntrySerializer(entry).data
|
data = TimeEntrySerializer(entry).data
|
||||||
|
|
||||||
assert data["project"] == str(project.id)
|
self.assertEqual(data["project"], str(project.id))
|
||||||
assert data["project_details"]["name"] == "Legacy Project"
|
self.assertEqual(data["project_details"]["name"], "Legacy Project")
|
||||||
assert data["project_details"]["is_deleted"] is True
|
self.assertTrue(data["project_details"]["is_deleted"])
|
||||||
assert data["tags"] == [str(tag.id)]
|
self.assertEqual(data["tags"], [str(tag.id)])
|
||||||
assert data["tag_details"][0]["name"] == "Legacy Tag"
|
self.assertEqual(data["tag_details"][0]["name"], "Legacy Tag")
|
||||||
assert data["tag_details"][0]["is_deleted"] is True
|
self.assertTrue(data["tag_details"][0]["is_deleted"])
|
||||||
|
|||||||
@@ -1,61 +1,62 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.services.time_entries import create_time_entry, stop_time_entry, update_time_entry
|
from apps.time_entries.services.time_entries import (
|
||||||
|
create_time_entry,
|
||||||
|
stop_time_entry,
|
||||||
|
update_time_entry,
|
||||||
|
)
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
class TimeEntryServiceTests(TestCase):
|
||||||
def workspace_owner(db):
|
@classmethod
|
||||||
user = User.objects.create_user(mobile="09121111111", password="secret123")
|
def setUpTestData(cls):
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
|
||||||
return user, workspace
|
cls.workspace = Workspace.objects.create(name="Core", owner=cls.user)
|
||||||
|
|
||||||
|
|
||||||
def test_create_time_entry_allows_only_one_running_timer_per_workspace(workspace_owner):
|
|
||||||
user, workspace = workspace_owner
|
|
||||||
|
|
||||||
|
def test_create_time_entry_allows_only_one_running_timer_per_workspace(self):
|
||||||
create_time_entry(
|
create_time_entry(
|
||||||
user=user,
|
user=self.user,
|
||||||
workspace_id=workspace.id,
|
workspace_id=self.workspace.id,
|
||||||
start_time=timezone.now(),
|
start_time=timezone.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
create_time_entry(
|
create_time_entry(
|
||||||
user=user,
|
user=self.user,
|
||||||
workspace_id=workspace.id,
|
workspace_id=self.workspace.id,
|
||||||
start_time=timezone.now() + timedelta(minutes=5),
|
start_time=timezone.now() + timedelta(minutes=5),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_stop_time_entry_sets_end_time_and_duration(self):
|
||||||
def test_stop_time_entry_sets_end_time_and_duration(workspace_owner):
|
|
||||||
user, workspace = workspace_owner
|
|
||||||
entry = create_time_entry(
|
entry = create_time_entry(
|
||||||
user=user,
|
user=self.user,
|
||||||
workspace_id=workspace.id,
|
workspace_id=self.workspace.id,
|
||||||
start_time=timezone.now() - timedelta(hours=1),
|
start_time=timezone.now() - timedelta(hours=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
stopped_entry = stop_time_entry(entry, end_time=timezone.now())
|
stopped_entry = stop_time_entry(entry, end_time=timezone.now())
|
||||||
|
|
||||||
assert stopped_entry.end_time is not None
|
self.assertIsNotNone(stopped_entry.end_time)
|
||||||
assert stopped_entry.duration is not None
|
self.assertIsNotNone(stopped_entry.duration)
|
||||||
|
|
||||||
|
def test_update_time_entry_preserves_deleted_project_and_tags(self):
|
||||||
def test_update_time_entry_preserves_deleted_project_and_tags(workspace_owner):
|
project = Project.objects.create(workspace=self.workspace, name="Deleted project")
|
||||||
user, workspace = workspace_owner
|
tag = Tag.objects.create(
|
||||||
project = Project.objects.create(workspace=workspace, name="Deleted project")
|
workspace=self.workspace,
|
||||||
tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#0f172a")
|
name="Deleted tag",
|
||||||
|
color="#0f172a",
|
||||||
|
)
|
||||||
entry = create_time_entry(
|
entry = create_time_entry(
|
||||||
user=user,
|
user=self.user,
|
||||||
workspace_id=workspace.id,
|
workspace_id=self.workspace.id,
|
||||||
start_time=timezone.now() - timedelta(hours=1),
|
start_time=timezone.now() - timedelta(hours=1),
|
||||||
end_time=timezone.now(),
|
end_time=timezone.now(),
|
||||||
project=project,
|
project=project,
|
||||||
@@ -73,6 +74,14 @@ def test_update_time_entry_preserves_deleted_project_and_tags(workspace_owner):
|
|||||||
description="After delete",
|
description="After delete",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert updated_entry.description == "After delete"
|
self.assertEqual(updated_entry.description, "After delete")
|
||||||
assert updated_entry.project_id == project.id
|
self.assertEqual(updated_entry.project_id, project.id)
|
||||||
assert list(Tag.all_objects.filter(time_entries=updated_entry).values_list("id", flat=True)) == [tag.id]
|
self.assertEqual(
|
||||||
|
list(
|
||||||
|
Tag.all_objects.filter(time_entries=updated_entry).values_list(
|
||||||
|
"id",
|
||||||
|
flat=True,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[tag.id],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
@@ -10,10 +10,15 @@ from apps.workspaces.models import Workspace
|
|||||||
|
|
||||||
|
|
||||||
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
||||||
return timezone.make_aware(datetime(year, month, day, hour, minute, second), timezone.get_current_timezone())
|
current_timezone = timezone.get_current_timezone()
|
||||||
|
return timezone.make_aware(
|
||||||
|
datetime(year, month, day, hour, minute, second),
|
||||||
|
current_timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
|
class TimeEntryViewTests(APITestCase):
|
||||||
|
def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
|
||||||
user = User.objects.create_user(mobile="09126666666", password="secret123")
|
user = User.objects.create_user(mobile="09126666666", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
|
||||||
@@ -31,10 +36,8 @@ def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
|
|||||||
start_time=make_aware(2026, 4, 24, 11, 0, 0),
|
start_time=make_aware(2026, 4, 24, 11, 0, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
client = APIClient()
|
self.client.force_authenticate(user=user)
|
||||||
client.force_authenticate(user=user)
|
response = self.client.get(
|
||||||
|
|
||||||
response = client.get(
|
|
||||||
"/api/time-entries/",
|
"/api/time-entries/",
|
||||||
{
|
{
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(workspace.id),
|
||||||
@@ -44,15 +47,17 @@ def test_time_entry_list_returns_grouped_payload_for_ended_entries(db):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["current_page_items_count"] == 1
|
self.assertEqual(response.data["current_page_items_count"], 1)
|
||||||
assert response.data["has_more"] is False
|
self.assertFalse(response.data["has_more"])
|
||||||
assert len(response.data["groups"]) == 1
|
self.assertEqual(len(response.data["groups"]), 1)
|
||||||
assert len(response.data["groups"][0]["days"]) == 1
|
self.assertEqual(len(response.data["groups"][0]["days"]), 1)
|
||||||
assert response.data["groups"][0]["days"][0]["entries"][0]["id"] == str(first_entry.id)
|
self.assertEqual(
|
||||||
|
response.data["groups"][0]["days"][0]["entries"][0]["id"],
|
||||||
|
str(first_entry.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_time_entry_update_preserves_current_deleted_tags(self):
|
||||||
def test_time_entry_update_preserves_current_deleted_tags(db):
|
|
||||||
user = User.objects.create_user(mobile="09127777777", password="secret123")
|
user = User.objects.create_user(mobile="09127777777", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
|
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
|
||||||
@@ -66,10 +71,8 @@ def test_time_entry_update_preserves_current_deleted_tags(db):
|
|||||||
entry.tags.set([tag])
|
entry.tags.set([tag])
|
||||||
tag.delete()
|
tag.delete()
|
||||||
|
|
||||||
client = APIClient()
|
self.client.force_authenticate(user=user)
|
||||||
client.force_authenticate(user=user)
|
response = self.client.patch(
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/time-entries/{entry.id}/",
|
f"/api/time-entries/{entry.id}/",
|
||||||
{
|
{
|
||||||
"description": "Still editable",
|
"description": "Still editable",
|
||||||
@@ -78,15 +81,18 @@ def test_time_entry_update_preserves_current_deleted_tags(db):
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["description"] == "Still editable"
|
self.assertEqual(response.data["description"], "Still editable")
|
||||||
assert response.data["tag_details"][0]["is_deleted"] is True
|
self.assertTrue(response.data["tag_details"][0]["is_deleted"])
|
||||||
|
|
||||||
|
def test_time_entry_update_rejects_new_deleted_tag_attachment(self):
|
||||||
def test_time_entry_update_rejects_new_deleted_tag_attachment(db):
|
|
||||||
user = User.objects.create_user(mobile="09128888888", password="secret123")
|
user = User.objects.create_user(mobile="09128888888", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
|
deleted_tag = Tag.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
name="Deleted tag",
|
||||||
|
color="#475569",
|
||||||
|
)
|
||||||
deleted_tag.delete()
|
deleted_tag.delete()
|
||||||
entry = TimeEntry.objects.create(
|
entry = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
@@ -96,25 +102,24 @@ def test_time_entry_update_rejects_new_deleted_tag_attachment(db):
|
|||||||
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
client = APIClient()
|
self.client.force_authenticate(user=user)
|
||||||
client.force_authenticate(user=user)
|
response = self.client.patch(
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/time-entries/{entry.id}/",
|
f"/api/time-entries/{entry.id}/",
|
||||||
{
|
{"tags": [str(deleted_tag.id)]},
|
||||||
"tags": [str(deleted_tag.id)],
|
|
||||||
},
|
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
self.assertEqual(response.status_code, 400)
|
||||||
assert "unavailable" in response.data["error"].lower()
|
self.assertIn("unavailable", response.data["error"].lower())
|
||||||
|
|
||||||
|
def test_time_entry_update_can_remove_current_deleted_tag(self):
|
||||||
def test_time_entry_update_can_remove_current_deleted_tag(db):
|
|
||||||
user = User.objects.create_user(mobile="09129999999", password="secret123")
|
user = User.objects.create_user(mobile="09129999999", password="secret123")
|
||||||
workspace = Workspace.objects.create(name="Core", owner=user)
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
deleted_tag = Tag.objects.create(workspace=workspace, name="Deleted tag", color="#475569")
|
deleted_tag = Tag.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
name="Deleted tag",
|
||||||
|
color="#475569",
|
||||||
|
)
|
||||||
entry = TimeEntry.objects.create(
|
entry = TimeEntry.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user=user,
|
user=user,
|
||||||
@@ -125,16 +130,12 @@ def test_time_entry_update_can_remove_current_deleted_tag(db):
|
|||||||
entry.tags.set([deleted_tag])
|
entry.tags.set([deleted_tag])
|
||||||
deleted_tag.delete()
|
deleted_tag.delete()
|
||||||
|
|
||||||
client = APIClient()
|
self.client.force_authenticate(user=user)
|
||||||
client.force_authenticate(user=user)
|
response = self.client.patch(
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/time-entries/{entry.id}/",
|
f"/api/time-entries/{entry.id}/",
|
||||||
{
|
{"tags": []},
|
||||||
"tags": [],
|
|
||||||
},
|
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
self.assertEqual(response.status_code, 200)
|
||||||
assert response.data["tags"] == []
|
self.assertEqual(response.data["tags"], [])
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
|
|
||||||
|
|
||||||
def test_profile_picture_delete_returns_profile_payload(db):
|
class ProfilePictureApiTests(APITestCase):
|
||||||
|
def test_profile_picture_delete_returns_profile_payload(self):
|
||||||
user = User.objects.create_user(mobile="09120000000", password="secret123")
|
user = User.objects.create_user(mobile="09120000000", password="secret123")
|
||||||
client = APIClient()
|
self.client.force_authenticate(user=user)
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
response = client.delete("/api/users/profile/picture/")
|
response = self.client.delete("/api/users/profile/picture/")
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
assert response.data["profile_picture"] is None
|
self.assertIsNone(response.data["profile_picture"])
|
||||||
|
|
||||||
user.refresh_from_db()
|
user.refresh_from_db()
|
||||||
assert not user.profile_picture
|
self.assertFalse(user.profile_picture)
|
||||||
|
|||||||
@@ -1,13 +1,65 @@
|
|||||||
from apps.users.tasks import send_verification_sms
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.users.tasks import _send_sms, send_verification_sms
|
||||||
|
|
||||||
|
|
||||||
def test_send_verification_sms_skips_real_delivery_without_api_key(settings):
|
class UserTaskTests(TestCase):
|
||||||
settings.SMS_APIKEY = ""
|
def test_send_verification_sms_skips_real_delivery_without_api_key(self):
|
||||||
|
with self.settings(SMS_APIKEY=""):
|
||||||
result = send_verification_sms("09123456789", "12345")
|
result = send_verification_sms("09123456789", "12345")
|
||||||
|
|
||||||
assert result == {
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
{
|
||||||
"mobile": "09123456789",
|
"mobile": "09123456789",
|
||||||
"code": "12345",
|
"code": "12345",
|
||||||
"sent": False,
|
"sent": False,
|
||||||
}
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("apps.users.tasks._send_sms")
|
||||||
|
def test_send_verification_sms_calls_sender_when_api_key_exists(self, send_sms):
|
||||||
|
send_sms.return_value = Mock(status_code=200)
|
||||||
|
|
||||||
|
with self.settings(SMS_APIKEY="configured-key"):
|
||||||
|
send_verification_sms("09123456789", "12345")
|
||||||
|
|
||||||
|
send_sms.assert_called_once_with(
|
||||||
|
"09123456789",
|
||||||
|
570574,
|
||||||
|
variables=[{"name": "OTP", "value": "12345"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("apps.users.tasks._send_sms", return_value=None)
|
||||||
|
def test_send_verification_sms_raises_when_delivery_fails(self, send_sms):
|
||||||
|
with self.settings(SMS_APIKEY="configured-key"):
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
send_verification_sms("09123456789", "12345")
|
||||||
|
|
||||||
|
send_sms.assert_called_once()
|
||||||
|
|
||||||
|
@patch("apps.users.tasks.requests.post")
|
||||||
|
def test_send_sms_posts_verify_payload(self, requests_post):
|
||||||
|
response = Mock(status_code=200, text="ok")
|
||||||
|
response.json.return_value = {"status": "1"}
|
||||||
|
requests_post.return_value = response
|
||||||
|
|
||||||
|
with self.settings(SMS_APIKEY="configured-key"):
|
||||||
|
result = _send_sms(
|
||||||
|
"09123456789",
|
||||||
|
570574,
|
||||||
|
variables=[{"name": "OTP", "value": "12345"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, response)
|
||||||
|
requests_post.assert_called_once()
|
||||||
|
self.assertEqual(
|
||||||
|
requests_post.call_args.kwargs["json"],
|
||||||
|
{
|
||||||
|
"mobile": "09123456789",
|
||||||
|
"templateId": 570574,
|
||||||
|
"parameters": [{"name": "OTP", "value": "12345"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
@@ -11,130 +10,128 @@ from apps.users.models import User
|
|||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class WorkspaceCapabilityTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
def setUpTestData(cls):
|
||||||
|
cls.owner = cls._user(1)
|
||||||
|
cls.admin = cls._user(2)
|
||||||
|
cls.member = cls._user(3)
|
||||||
|
cls.guest = cls._user(4)
|
||||||
|
cls.extra_owner = cls._user(5)
|
||||||
|
|
||||||
|
cls.workspace = Workspace.objects.create(name="Ops", description="", owner=cls.owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.admin,
|
||||||
|
role=WorkspaceMembership.Role.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.guest,
|
||||||
|
role=WorkspaceMembership.Role.GUEST,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
cls.project = Project.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
name="Alpha",
|
||||||
|
description="",
|
||||||
|
)
|
||||||
|
|
||||||
def _user(index: int) -> User:
|
@staticmethod
|
||||||
|
def _user(index):
|
||||||
return User.objects.create_user(
|
return User.objects.create_user(
|
||||||
mobile=f"091255500{index:02d}",
|
mobile=f"091255500{index:02d}",
|
||||||
password="secret123",
|
password="secret123",
|
||||||
first_name=f"User{index}",
|
first_name=f"User{index}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_member_is_read_only_for_clients_and_projects(self):
|
||||||
@pytest.fixture()
|
client = Client.objects.create(
|
||||||
def owner(db):
|
workspace=self.workspace,
|
||||||
return _user(1)
|
name="Existing Client",
|
||||||
|
notes="",
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def admin(db):
|
|
||||||
return _user(2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def member(db):
|
|
||||||
return _user(3)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def guest(db):
|
|
||||||
return _user(4)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def extra_owner(db):
|
|
||||||
return _user(5)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def workspace(owner, admin, member, guest):
|
|
||||||
workspace = Workspace.objects.create(name="Ops", description="", owner=owner)
|
|
||||||
WorkspaceMembership.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=admin,
|
|
||||||
role=WorkspaceMembership.Role.ADMIN,
|
|
||||||
is_active=True,
|
|
||||||
)
|
)
|
||||||
WorkspaceMembership.objects.create(
|
self.client.force_authenticate(user=self.member)
|
||||||
workspace=workspace,
|
|
||||||
user=member,
|
|
||||||
role=WorkspaceMembership.Role.MEMBER,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
WorkspaceMembership.objects.create(
|
|
||||||
workspace=workspace,
|
|
||||||
user=guest,
|
|
||||||
role=WorkspaceMembership.Role.GUEST,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
return workspace
|
|
||||||
|
|
||||||
|
client_response = self.client.post(
|
||||||
@pytest.fixture()
|
|
||||||
def project(workspace, owner, member):
|
|
||||||
return Project.objects.create(workspace=workspace, name="Alpha", description="")
|
|
||||||
|
|
||||||
|
|
||||||
def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project):
|
|
||||||
client = Client.objects.create(workspace=workspace, name="Existing Client", notes="")
|
|
||||||
api_client.force_authenticate(user=member)
|
|
||||||
|
|
||||||
client_response = api_client.post(
|
|
||||||
"/api/clients/",
|
"/api/clients/",
|
||||||
{"workspace_id": str(workspace.id), "name": "Acme", "notes": ""},
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Acme",
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
update_client_response = api_client.patch(
|
update_client_response = self.client.patch(
|
||||||
f"/api/clients/{client.id}/",
|
f"/api/clients/{client.id}/",
|
||||||
{"name": "Updated"},
|
{"name": "Updated"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
delete_client_response = api_client.delete(f"/api/clients/{client.id}/")
|
delete_client_response = self.client.delete(f"/api/clients/{client.id}/")
|
||||||
project_response = api_client.post(
|
project_response = self.client.post(
|
||||||
"/api/projects/",
|
"/api/projects/",
|
||||||
{"workspace": str(workspace.id), "name": "Beta", "description": "", "client": None},
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"name": "Beta",
|
||||||
|
"description": "",
|
||||||
|
"client": None,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
update_project_response = api_client.patch(
|
update_project_response = self.client.patch(
|
||||||
f"/api/projects/{project.id}/",
|
f"/api/projects/{self.project.id}/",
|
||||||
{"description": "Blocked edit"},
|
{"description": "Blocked edit"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/")
|
archive_project_response = self.client.post(
|
||||||
delete_project_response = api_client.delete(f"/api/projects/{project.id}/")
|
f"/api/projects/{self.project.id}/archive/"
|
||||||
assert client_response.status_code == 403
|
)
|
||||||
assert update_client_response.status_code == 403
|
delete_project_response = self.client.delete(f"/api/projects/{self.project.id}/")
|
||||||
assert delete_client_response.status_code == 403
|
|
||||||
assert project_response.status_code == 403
|
|
||||||
assert update_project_response.status_code == 403
|
|
||||||
assert archive_project_response.status_code == 403
|
|
||||||
assert delete_project_response.status_code == 403
|
|
||||||
|
|
||||||
|
self.assertEqual(client_response.status_code, 403)
|
||||||
|
self.assertEqual(update_client_response.status_code, 403)
|
||||||
|
self.assertEqual(delete_client_response.status_code, 403)
|
||||||
|
self.assertEqual(project_response.status_code, 403)
|
||||||
|
self.assertEqual(update_project_response.status_code, 403)
|
||||||
|
self.assertEqual(archive_project_response.status_code, 403)
|
||||||
|
self.assertEqual(delete_project_response.status_code, 403)
|
||||||
|
|
||||||
def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace):
|
def test_member_can_create_tags_and_manage_own_time_entries(self):
|
||||||
tag = Tag.objects.create(workspace=workspace, name="Existing", color="#000000")
|
tag = Tag.objects.create(
|
||||||
api_client.force_authenticate(user=member)
|
workspace=self.workspace,
|
||||||
|
name="Existing",
|
||||||
|
color="#000000",
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
create_tag_response = api_client.post(
|
create_tag_response = self.client.post(
|
||||||
"/api/tags/",
|
"/api/tags/",
|
||||||
{"workspace_id": str(workspace.id), "name": "New Tag", "color": "#ffffff"},
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "New Tag",
|
||||||
|
"color": "#ffffff",
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
update_tag_response = api_client.patch(
|
update_tag_response = self.client.patch(
|
||||||
f"/api/tags/{tag.id}/",
|
f"/api/tags/{tag.id}/",
|
||||||
{"name": "Changed"},
|
{"name": "Changed"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
delete_tag_response = api_client.delete(f"/api/tags/{tag.id}/")
|
delete_tag_response = self.client.delete(f"/api/tags/{tag.id}/")
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
create_entry_response = api_client.post(
|
create_entry_response = self.client.post(
|
||||||
"/api/time-entries/",
|
"/api/time-entries/",
|
||||||
{
|
{
|
||||||
"workspace_id": str(workspace.id),
|
"workspace_id": str(self.workspace.id),
|
||||||
"start_time": now.isoformat(),
|
"start_time": now.isoformat(),
|
||||||
"end_time": (now + timedelta(hours=1)).isoformat(),
|
"end_time": (now + timedelta(hours=1)).isoformat(),
|
||||||
"description": "Focus block",
|
"description": "Focus block",
|
||||||
@@ -142,195 +139,249 @@ def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, m
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert create_tag_response.status_code == 201
|
self.assertEqual(create_tag_response.status_code, 201)
|
||||||
assert update_tag_response.status_code == 403
|
self.assertEqual(update_tag_response.status_code, 403)
|
||||||
assert delete_tag_response.status_code == 403
|
self.assertEqual(delete_tag_response.status_code, 403)
|
||||||
assert create_entry_response.status_code == 201
|
self.assertEqual(create_entry_response.status_code, 201)
|
||||||
|
|
||||||
entry_id = create_entry_response.data["id"]
|
entry_id = create_entry_response.data["id"]
|
||||||
update_entry_response = api_client.patch(
|
update_entry_response = self.client.patch(
|
||||||
f"/api/time-entries/{entry_id}/",
|
f"/api/time-entries/{entry_id}/",
|
||||||
{"description": "Updated focus block"},
|
{"description": "Updated focus block"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
delete_entry_response = api_client.delete(f"/api/time-entries/{entry_id}/")
|
delete_entry_response = self.client.delete(f"/api/time-entries/{entry_id}/")
|
||||||
|
|
||||||
assert update_entry_response.status_code == 200
|
self.assertEqual(update_entry_response.status_code, 200)
|
||||||
assert delete_entry_response.status_code == 204
|
self.assertEqual(delete_entry_response.status_code, 204)
|
||||||
|
|
||||||
|
def test_guest_is_read_only_for_workspace_resources(self):
|
||||||
|
Client.objects.create(workspace=self.workspace, name="Visible Client", notes="")
|
||||||
|
Tag.objects.create(workspace=self.workspace, name="Visible Tag", color="#123456")
|
||||||
|
|
||||||
def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, workspace, project):
|
self.client.force_authenticate(user=self.guest)
|
||||||
Client.objects.create(workspace=workspace, name="Visible Client", notes="")
|
|
||||||
Tag.objects.create(workspace=workspace, name="Visible Tag", color="#123456")
|
|
||||||
|
|
||||||
api_client.force_authenticate(user=guest)
|
list_clients_response = self.client.get(
|
||||||
|
f"/api/clients/?workspace={self.workspace.id}"
|
||||||
list_clients_response = api_client.get(f"/api/clients/?workspace={workspace.id}")
|
)
|
||||||
list_projects_response = api_client.get(f"/api/projects/?workspace={workspace.id}")
|
list_projects_response = self.client.get(
|
||||||
create_tag_response = api_client.post(
|
f"/api/projects/?workspace={self.workspace.id}"
|
||||||
|
)
|
||||||
|
create_tag_response = self.client.post(
|
||||||
"/api/tags/",
|
"/api/tags/",
|
||||||
{"workspace_id": str(workspace.id), "name": "Blocked", "color": "#ffffff"},
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Blocked",
|
||||||
|
"color": "#ffffff",
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
create_entry_response = api_client.post(
|
create_entry_response = self.client.post(
|
||||||
"/api/time-entries/",
|
"/api/time-entries/",
|
||||||
{
|
{
|
||||||
"workspace_id": str(workspace.id),
|
"workspace_id": str(self.workspace.id),
|
||||||
"start_time": timezone.now().isoformat(),
|
"start_time": timezone.now().isoformat(),
|
||||||
"description": "Blocked guest entry",
|
"description": "Blocked guest entry",
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
edit_project_response = api_client.patch(
|
edit_project_response = self.client.patch(
|
||||||
f"/api/projects/{project.id}/",
|
f"/api/projects/{self.project.id}/",
|
||||||
{"description": "Blocked"},
|
{"description": "Blocked"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert list_clients_response.status_code == 200
|
self.assertEqual(list_clients_response.status_code, 200)
|
||||||
assert list_projects_response.status_code == 200
|
self.assertEqual(list_projects_response.status_code, 200)
|
||||||
assert create_tag_response.status_code == 403
|
self.assertEqual(create_tag_response.status_code, 403)
|
||||||
assert create_entry_response.status_code == 403
|
self.assertEqual(create_entry_response.status_code, 403)
|
||||||
assert edit_project_response.status_code == 403
|
self.assertEqual(edit_project_response.status_code, 403)
|
||||||
|
|
||||||
|
def test_member_cannot_edit_project(self):
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
def test_member_cannot_edit_project(api_client, member, project):
|
response = self.client.patch(
|
||||||
api_client.force_authenticate(user=member)
|
f"/api/projects/{self.project.id}/",
|
||||||
|
|
||||||
response = api_client.patch(
|
|
||||||
f"/api/projects/{project.id}/",
|
|
||||||
{"description": "Still blocked"},
|
{"description": "Still blocked"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_member_can_list_workspace_members_with_restricted_user_fields(self):
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
def test_member_can_list_workspace_members_with_restricted_user_fields(api_client, member, workspace):
|
response = self.client.get(
|
||||||
api_client.force_authenticate(user=member)
|
f"/api/workspace-memberships/?workspace={self.workspace.id}"
|
||||||
|
)
|
||||||
|
|
||||||
response = api_client.get(f"/api/workspace-memberships/?workspace={workspace.id}")
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = (
|
||||||
assert response.status_code == 200
|
response.data.get("items", response.data)
|
||||||
payload = response.data.get("items", response.data) if isinstance(response.data, dict) else response.data
|
if isinstance(response.data, dict)
|
||||||
assert len(payload) >= 1
|
else response.data
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(payload), 1)
|
||||||
first_user = payload[0]["user"]
|
first_user = payload[0]["user"]
|
||||||
assert "mobile" not in first_user
|
self.assertNotIn("mobile", first_user)
|
||||||
assert "email" not in first_user
|
self.assertNotIn("email", first_user)
|
||||||
|
|
||||||
|
def test_owner_can_list_workspace_members_with_full_user_fields(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
def test_owner_can_list_workspace_members_with_full_user_fields(api_client, owner, workspace):
|
response = self.client.get(
|
||||||
api_client.force_authenticate(user=owner)
|
f"/api/workspace-memberships/?workspace={self.workspace.id}"
|
||||||
|
)
|
||||||
|
|
||||||
response = api_client.get(f"/api/workspace-memberships/?workspace={workspace.id}")
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = (
|
||||||
assert response.status_code == 200
|
response.data.get("items", response.data)
|
||||||
payload = response.data.get("items", response.data) if isinstance(response.data, dict) else response.data
|
if isinstance(response.data, dict)
|
||||||
assert len(payload) >= 1
|
else response.data
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(payload), 1)
|
||||||
first_user = payload[0]["user"]
|
first_user = payload[0]["user"]
|
||||||
assert "mobile" in first_user
|
self.assertIn("mobile", first_user)
|
||||||
|
|
||||||
|
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(self):
|
||||||
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(
|
|
||||||
api_client, owner, admin, extra_owner, workspace
|
|
||||||
):
|
|
||||||
extra_owner_membership = WorkspaceMembership.objects.create(
|
extra_owner_membership = WorkspaceMembership.objects.create(
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
user=extra_owner,
|
user=self.extra_owner,
|
||||||
role=WorkspaceMembership.Role.OWNER,
|
role=WorkspaceMembership.Role.OWNER,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_client.force_authenticate(user=admin)
|
self.client.force_authenticate(user=self.admin)
|
||||||
admin_response = api_client.patch(
|
admin_response = self.client.patch(
|
||||||
f"/api/workspace-memberships/{extra_owner_membership.id}/",
|
f"/api/workspace-memberships/{extra_owner_membership.id}/",
|
||||||
{"role": WorkspaceMembership.Role.ADMIN},
|
{"role": WorkspaceMembership.Role.ADMIN},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
api_client.force_authenticate(user=owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
owner_response = api_client.patch(
|
owner_response = self.client.patch(
|
||||||
f"/api/workspace-memberships/{extra_owner_membership.id}/",
|
f"/api/workspace-memberships/{extra_owner_membership.id}/",
|
||||||
{"role": WorkspaceMembership.Role.ADMIN},
|
{"role": WorkspaceMembership.Role.ADMIN},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert admin_response.status_code == 403
|
self.assertEqual(admin_response.status_code, 403)
|
||||||
assert owner_response.status_code == 200
|
self.assertEqual(owner_response.status_code, 200)
|
||||||
|
|
||||||
|
def test_admin_cannot_add_or_change_admin_memberships(self):
|
||||||
|
admin_membership = WorkspaceMembership.objects.get(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.admin,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
def test_admin_cannot_add_or_change_admin_memberships(api_client, owner, admin, member, workspace):
|
self.client.force_authenticate(user=self.admin)
|
||||||
admin_membership = WorkspaceMembership.objects.get(workspace=workspace, user=admin, is_deleted=False)
|
create_response = self.client.post(
|
||||||
|
|
||||||
api_client.force_authenticate(user=admin)
|
|
||||||
create_response = api_client.post(
|
|
||||||
"/api/workspace-memberships/",
|
"/api/workspace-memberships/",
|
||||||
{
|
{
|
||||||
"workspace": str(workspace.id),
|
"workspace": str(self.workspace.id),
|
||||||
"user": str(member.id),
|
"user": str(self.member.id),
|
||||||
"role": WorkspaceMembership.Role.ADMIN,
|
"role": WorkspaceMembership.Role.ADMIN,
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
update_response = api_client.patch(
|
update_response = self.client.patch(
|
||||||
f"/api/workspace-memberships/{admin_membership.id}/",
|
f"/api/workspace-memberships/{admin_membership.id}/",
|
||||||
{"role": WorkspaceMembership.Role.MEMBER},
|
{"role": WorkspaceMembership.Role.MEMBER},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
delete_response = api_client.delete(f"/api/workspace-memberships/{admin_membership.id}/")
|
delete_response = self.client.delete(
|
||||||
|
f"/api/workspace-memberships/{admin_membership.id}/"
|
||||||
|
)
|
||||||
|
|
||||||
assert create_response.status_code == 403
|
self.assertEqual(create_response.status_code, 403)
|
||||||
assert update_response.status_code == 403
|
self.assertEqual(update_response.status_code, 403)
|
||||||
assert delete_response.status_code == 403
|
self.assertEqual(delete_response.status_code, 403)
|
||||||
|
|
||||||
|
def test_admin_can_delete_only_owned_clients_tags_and_projects(self):
|
||||||
def test_admin_can_delete_only_owned_clients_tags_and_projects(api_client, owner, admin, workspace):
|
self.client.force_authenticate(user=self.owner)
|
||||||
api_client.force_authenticate(user=owner)
|
owner_client_response = self.client.post(
|
||||||
owner_client_response = api_client.post(
|
|
||||||
"/api/clients/",
|
"/api/clients/",
|
||||||
{"workspace_id": str(workspace.id), "name": "Owner Client", "notes": ""},
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Owner Client",
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
owner_tag_response = api_client.post(
|
owner_tag_response = self.client.post(
|
||||||
"/api/tags/",
|
"/api/tags/",
|
||||||
{"workspace_id": str(workspace.id), "name": "Owner Tag", "color": "#123456"},
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Owner Tag",
|
||||||
|
"color": "#123456",
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
owner_project_response = api_client.post(
|
owner_project_response = self.client.post(
|
||||||
"/api/projects/",
|
"/api/projects/",
|
||||||
{"workspace": str(workspace.id), "name": "Owner Project", "description": "", "client": None},
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"name": "Owner Project",
|
||||||
|
"description": "",
|
||||||
|
"client": None,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
api_client.force_authenticate(user=admin)
|
self.client.force_authenticate(user=self.admin)
|
||||||
admin_client_response = api_client.post(
|
admin_client_response = self.client.post(
|
||||||
"/api/clients/",
|
"/api/clients/",
|
||||||
{"workspace_id": str(workspace.id), "name": "Admin Client", "notes": ""},
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Admin Client",
|
||||||
|
"notes": "",
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
admin_tag_response = api_client.post(
|
admin_tag_response = self.client.post(
|
||||||
"/api/tags/",
|
"/api/tags/",
|
||||||
{"workspace_id": str(workspace.id), "name": "Admin Tag", "color": "#654321"},
|
{
|
||||||
|
"workspace_id": str(self.workspace.id),
|
||||||
|
"name": "Admin Tag",
|
||||||
|
"color": "#654321",
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
admin_project_response = api_client.post(
|
admin_project_response = self.client.post(
|
||||||
"/api/projects/",
|
"/api/projects/",
|
||||||
{"workspace": str(workspace.id), "name": "Admin Project", "description": "", "client": None},
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"name": "Admin Project",
|
||||||
|
"description": "",
|
||||||
|
"client": None,
|
||||||
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
delete_owner_client = api_client.delete(f"/api/clients/{owner_client_response.data['id']}/")
|
delete_owner_client = self.client.delete(
|
||||||
delete_owner_tag = api_client.delete(f"/api/tags/{owner_tag_response.data['id']}/")
|
f"/api/clients/{owner_client_response.data['id']}/"
|
||||||
delete_owner_project = api_client.delete(f"/api/projects/{owner_project_response.data['id']}/")
|
)
|
||||||
|
delete_owner_tag = self.client.delete(
|
||||||
|
f"/api/tags/{owner_tag_response.data['id']}/"
|
||||||
|
)
|
||||||
|
delete_owner_project = self.client.delete(
|
||||||
|
f"/api/projects/{owner_project_response.data['id']}/"
|
||||||
|
)
|
||||||
|
|
||||||
delete_admin_client = api_client.delete(f"/api/clients/{admin_client_response.data['id']}/")
|
delete_admin_client = self.client.delete(
|
||||||
delete_admin_tag = api_client.delete(f"/api/tags/{admin_tag_response.data['id']}/")
|
f"/api/clients/{admin_client_response.data['id']}/"
|
||||||
delete_admin_project = api_client.delete(f"/api/projects/{admin_project_response.data['id']}/")
|
)
|
||||||
|
delete_admin_tag = self.client.delete(
|
||||||
|
f"/api/tags/{admin_tag_response.data['id']}/"
|
||||||
|
)
|
||||||
|
delete_admin_project = self.client.delete(
|
||||||
|
f"/api/projects/{admin_project_response.data['id']}/"
|
||||||
|
)
|
||||||
|
|
||||||
assert delete_owner_client.status_code == 403
|
self.assertEqual(delete_owner_client.status_code, 403)
|
||||||
assert delete_owner_tag.status_code == 403
|
self.assertEqual(delete_owner_tag.status_code, 403)
|
||||||
assert delete_owner_project.status_code in {403, 404}
|
self.assertIn(delete_owner_project.status_code, {403, 404})
|
||||||
|
self.assertEqual(delete_admin_client.status_code, 204)
|
||||||
assert delete_admin_client.status_code == 204
|
self.assertEqual(delete_admin_tag.status_code, 204)
|
||||||
assert delete_admin_tag.status_code == 204
|
self.assertEqual(delete_admin_project.status_code, 204)
|
||||||
assert delete_admin_project.status_code == 204
|
|
||||||
|
|||||||
@@ -1,128 +1,184 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
from django.test import TestCase
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.time_entries.services.rates import resolve_rate
|
from apps.time_entries.services.rates import resolve_rate
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
from apps.workspaces.models import (
|
||||||
|
PriceUnit,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMembership,
|
||||||
|
WorkspaceUserRate,
|
||||||
|
)
|
||||||
|
from apps.workspaces.services.rates import (
|
||||||
|
update_workspace_user_rate,
|
||||||
|
upsert_workspace_user_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class WorkspaceRateTests(APITestCase):
|
||||||
def api_client():
|
@classmethod
|
||||||
return APIClient()
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(mobile="09127770001", password="secret123")
|
||||||
|
cls.admin = User.objects.create_user(mobile="09127770002", password="secret123")
|
||||||
|
cls.member = User.objects.create_user(mobile="09127770003", password="secret123")
|
||||||
|
|
||||||
|
cls.workspace = Workspace.objects.create(name="Rates", owner=cls.owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.admin,
|
||||||
|
role=WorkspaceMembership.Role.ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
cls.project = Project.objects.create(workspace=cls.workspace, name="Billing")
|
||||||
|
|
||||||
@pytest.fixture()
|
PriceUnit.objects.create(
|
||||||
def owner(db):
|
code="USD",
|
||||||
return User.objects.create_user(mobile="09127770001", password="secret123")
|
name="US Dollar",
|
||||||
|
local_name="Dollar",
|
||||||
|
symbol="$",
|
||||||
|
)
|
||||||
|
PriceUnit.objects.create(
|
||||||
|
code="EUR",
|
||||||
|
name="Euro",
|
||||||
|
local_name="Euro",
|
||||||
|
symbol="EUR",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolve_rate_uses_workspace_user_rate(self):
|
||||||
@pytest.fixture()
|
|
||||||
def admin(db):
|
|
||||||
return User.objects.create_user(mobile="09127770002", password="secret123")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def member(db):
|
|
||||||
return User.objects.create_user(mobile="09127770003", password="secret123")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def workspace(owner, admin, member):
|
|
||||||
workspace = Workspace.objects.create(name="Rates", owner=owner)
|
|
||||||
WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True)
|
|
||||||
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True)
|
|
||||||
return workspace
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def project(workspace, owner, admin, member):
|
|
||||||
return Project.objects.create(workspace=workspace, name="Billing")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def price_units(db):
|
|
||||||
PriceUnit.objects.create(code="USD", name="US Dollar", local_name="دلار آمریکا", symbol="$")
|
|
||||||
PriceUnit.objects.create(code="EUR", name="Euro", local_name="یورو", symbol="€")
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_rate_uses_workspace_user_rate(workspace, project, member):
|
|
||||||
WorkspaceUserRate.objects.create(
|
WorkspaceUserRate.objects.create(
|
||||||
workspace=workspace,
|
workspace=self.workspace,
|
||||||
user=member,
|
user=self.member,
|
||||||
hourly_rate=Decimal("40.00"),
|
hourly_rate=Decimal("40.00"),
|
||||||
currency="EUR",
|
currency="EUR",
|
||||||
effective_from=project.created_at,
|
effective_from=self.project.created_at,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
hourly_rate, currency = resolve_rate(member, project)
|
hourly_rate, currency = resolve_rate(self.member, self.project)
|
||||||
|
|
||||||
assert hourly_rate == Decimal("40.00")
|
self.assertEqual(hourly_rate, Decimal("40.00"))
|
||||||
assert currency == "EUR"
|
self.assertEqual(currency, "EUR")
|
||||||
|
|
||||||
|
def test_resolve_rate_returns_none_when_workspace_rate_is_missing(self):
|
||||||
|
hourly_rate, currency = resolve_rate(self.member, self.project)
|
||||||
|
|
||||||
def test_resolve_rate_falls_back_to_workspace_user_rate(workspace, project, member):
|
self.assertIsNone(hourly_rate)
|
||||||
WorkspaceUserRate.objects.create(
|
self.assertEqual(currency, "")
|
||||||
workspace=workspace,
|
|
||||||
user=member,
|
|
||||||
hourly_rate=Decimal("40.00"),
|
|
||||||
currency="EUR",
|
|
||||||
effective_from=project.created_at,
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
hourly_rate, currency = resolve_rate(member, project)
|
def test_admin_can_manage_workspace_user_rates(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
assert hourly_rate == Decimal("40.00")
|
create_response = self.client.post(
|
||||||
assert currency == "EUR"
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_manage_workspace_user_rates(api_client, admin, member, workspace, price_units):
|
|
||||||
api_client.force_authenticate(user=admin)
|
|
||||||
|
|
||||||
create_response = api_client.post(
|
|
||||||
"/api/workspace-user-rates/",
|
"/api/workspace-user-rates/",
|
||||||
{
|
{
|
||||||
"workspace_id": str(workspace.id),
|
"workspace_id": str(self.workspace.id),
|
||||||
"user_id": str(member.id),
|
"user_id": str(self.member.id),
|
||||||
"hourly_rate": "35.50",
|
"hourly_rate": "35.50",
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert create_response.status_code == 201
|
self.assertEqual(create_response.status_code, 201)
|
||||||
rate_id = create_response.data["id"]
|
rate_id = create_response.data["id"]
|
||||||
assert WorkspaceUserRate.objects.filter(id=rate_id, is_deleted=False).exists()
|
self.assertTrue(
|
||||||
|
WorkspaceUserRate.objects.filter(id=rate_id, is_deleted=False).exists()
|
||||||
|
)
|
||||||
|
|
||||||
update_response = api_client.patch(
|
update_response = self.client.patch(
|
||||||
f"/api/workspace-user-rates/{rate_id}/",
|
f"/api/workspace-user-rates/{rate_id}/",
|
||||||
{"hourly_rate": "42.00"},
|
{"hourly_rate": "42.00"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert update_response.status_code == 200
|
self.assertEqual(update_response.status_code, 200)
|
||||||
assert update_response.data["hourly_rate"] == "42.00"
|
self.assertEqual(update_response.data["hourly_rate"], "42.00")
|
||||||
|
|
||||||
delete_response = api_client.delete(f"/api/workspace-user-rates/{rate_id}/")
|
delete_response = self.client.delete(f"/api/workspace-user-rates/{rate_id}/")
|
||||||
assert delete_response.status_code == 204
|
self.assertEqual(delete_response.status_code, 204)
|
||||||
assert WorkspaceUserRate.all_objects.get(id=rate_id).is_deleted is True
|
self.assertTrue(WorkspaceUserRate.all_objects.get(id=rate_id).is_deleted)
|
||||||
|
|
||||||
|
def test_member_cannot_manage_rates(self):
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
def test_member_cannot_manage_rates(api_client, member, workspace, price_units):
|
response = self.client.post(
|
||||||
api_client.force_authenticate(user=member)
|
|
||||||
|
|
||||||
workspace_response = api_client.post(
|
|
||||||
"/api/workspace-user-rates/",
|
"/api/workspace-user-rates/",
|
||||||
{
|
{
|
||||||
"workspace_id": str(workspace.id),
|
"workspace_id": str(self.workspace.id),
|
||||||
"user_id": str(member.id),
|
"user_id": str(self.member.id),
|
||||||
"hourly_rate": "25.00",
|
"hourly_rate": "25.00",
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert workspace_response.status_code == 403
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceRateServiceTests(TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(mobile="09127770011", password="secret123")
|
||||||
|
cls.member = User.objects.create_user(mobile="09127770012", password="secret123")
|
||||||
|
cls.workspace = Workspace.objects.create(name="Rate Services", owner=cls.owner)
|
||||||
|
|
||||||
|
def test_upsert_workspace_user_rate_creates_uppercase_currency_rate(self):
|
||||||
|
rate = upsert_workspace_user_rate(
|
||||||
|
self.workspace,
|
||||||
|
self.member.id,
|
||||||
|
Decimal("12.50"),
|
||||||
|
"usd",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(rate.hourly_rate, Decimal("12.50"))
|
||||||
|
self.assertEqual(rate.currency, "USD")
|
||||||
|
self.assertTrue(rate.is_active)
|
||||||
|
|
||||||
|
def test_upsert_workspace_user_rate_updates_existing_inactive_rate(self):
|
||||||
|
rate = WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("10.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from=self.workspace.created_at,
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = upsert_workspace_user_rate(
|
||||||
|
self.workspace,
|
||||||
|
self.member.id,
|
||||||
|
Decimal("20.00"),
|
||||||
|
"eur",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(updated.id, rate.id)
|
||||||
|
self.assertEqual(updated.hourly_rate, Decimal("20.00"))
|
||||||
|
self.assertEqual(updated.currency, "EUR")
|
||||||
|
self.assertTrue(updated.is_active)
|
||||||
|
|
||||||
|
def test_update_workspace_user_rate_updates_only_changed_fields(self):
|
||||||
|
rate = WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("10.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from=self.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = update_workspace_user_rate(
|
||||||
|
rate,
|
||||||
|
hourly_rate=Decimal("15.00"),
|
||||||
|
currency="gbp",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(updated.hourly_rate, Decimal("15.00"))
|
||||||
|
self.assertEqual(updated.currency, "GBP")
|
||||||
|
|||||||
Reference in New Issue
Block a user