test(backend): convert existing app suites to unittest

This commit is contained in:
2026-04-30 12:41:54 +03:30
parent 204225dd16
commit 8774a4d4dc
16 changed files with 1785 additions and 1780 deletions

View File

@@ -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}&section=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}&section=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"])
)

View 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

View File

@@ -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), [])

View File

@@ -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")

View File

@@ -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"]))

View File

@@ -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)},
)

View File

@@ -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))

View File

@@ -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")

View File

@@ -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])

View File

@@ -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"])

View File

@@ -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],
)

View File

@@ -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"], [])

View File

@@ -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)

View File

@@ -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"}],
},
)

View File

@@ -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

View File

@@ -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")