from datetime import timedelta from decimal import Decimal from django.utils import timezone from rest_framework.test import APITestCase from apps.clients.models import Client from apps.projects.models import Project from apps.tags.models import Tag from apps.users.models import User from apps.workspaces.models import ( HourlyRateHistory, PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate, ) class WorkspaceCapabilityTests(APITestCase): @classmethod 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="", ) @staticmethod def _user(index): return User.objects.create_user( mobile=f"091255500{index:02d}", password="secret123", first_name=f"User{index}", ) def test_member_is_read_only_for_clients_and_projects(self): client = Client.objects.create( workspace=self.workspace, name="Existing Client", notes="", ) self.client.force_authenticate(user=self.member) client_response = self.client.post( "/api/clients/", { "workspace_id": str(self.workspace.id), "name": "Acme", "notes": "", }, format="json", ) update_client_response = self.client.patch( f"/api/clients/{client.id}/", {"name": "Updated"}, format="json", ) delete_client_response = self.client.delete(f"/api/clients/{client.id}/") project_response = self.client.post( "/api/projects/", { "workspace": str(self.workspace.id), "name": "Beta", "description": "", "client": None, }, format="json", ) update_project_response = self.client.patch( f"/api/projects/{self.project.id}/", {"description": "Blocked edit"}, format="json", ) archive_project_response = self.client.post( f"/api/projects/{self.project.id}/archive/" ) delete_project_response = self.client.delete(f"/api/projects/{self.project.id}/") 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(self): tag = Tag.objects.create( workspace=self.workspace, name="Existing", color="#000000", ) self.client.force_authenticate(user=self.member) create_tag_response = self.client.post( "/api/tags/", { "workspace_id": str(self.workspace.id), "name": "New Tag", "color": "#ffffff", }, format="json", ) update_tag_response = self.client.patch( f"/api/tags/{tag.id}/", {"name": "Changed"}, format="json", ) delete_tag_response = self.client.delete(f"/api/tags/{tag.id}/") now = timezone.now() create_entry_response = self.client.post( "/api/time-entries/", { "workspace_id": str(self.workspace.id), "start_time": now.isoformat(), "end_time": (now + timedelta(hours=1)).isoformat(), "description": "Focus block", }, format="json", ) self.assertEqual(create_tag_response.status_code, 201) self.assertEqual(update_tag_response.status_code, 403) self.assertEqual(delete_tag_response.status_code, 403) self.assertEqual(create_entry_response.status_code, 201) entry_id = create_entry_response.data["id"] update_entry_response = self.client.patch( f"/api/time-entries/{entry_id}/", {"description": "Updated focus block"}, format="json", ) delete_entry_response = self.client.delete(f"/api/time-entries/{entry_id}/") self.assertEqual(update_entry_response.status_code, 200) 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") self.client.force_authenticate(user=self.guest) list_clients_response = self.client.get( f"/api/clients/?workspace={self.workspace.id}" ) list_projects_response = self.client.get( f"/api/projects/?workspace={self.workspace.id}" ) create_tag_response = self.client.post( "/api/tags/", { "workspace_id": str(self.workspace.id), "name": "Blocked", "color": "#ffffff", }, format="json", ) create_entry_response = self.client.post( "/api/time-entries/", { "workspace_id": str(self.workspace.id), "start_time": timezone.now().isoformat(), "description": "Blocked guest entry", }, format="json", ) edit_project_response = self.client.patch( f"/api/projects/{self.project.id}/", {"description": "Blocked"}, format="json", ) self.assertEqual(list_clients_response.status_code, 200) self.assertEqual(list_projects_response.status_code, 200) self.assertEqual(create_tag_response.status_code, 403) self.assertEqual(create_entry_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) response = self.client.patch( f"/api/projects/{self.project.id}/", {"description": "Still blocked"}, format="json", ) 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) response = self.client.get( f"/api/workspace-memberships/?workspace={self.workspace.id}" ) self.assertEqual(response.status_code, 200) payload = ( response.data.get("items", response.data) if isinstance(response.data, dict) else response.data ) self.assertGreaterEqual(len(payload), 1) first_user = payload[0]["user"] self.assertNotIn("mobile", 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) response = self.client.get( f"/api/workspace-memberships/?workspace={self.workspace.id}" ) self.assertEqual(response.status_code, 200) payload = ( response.data.get("items", response.data) if isinstance(response.data, dict) else response.data ) self.assertGreaterEqual(len(payload), 1) first_user = payload[0]["user"] self.assertIn("mobile", first_user) def test_admin_cannot_change_owner_membership_but_canonical_owner_can(self): extra_owner_membership = WorkspaceMembership.objects.create( workspace=self.workspace, user=self.extra_owner, role=WorkspaceMembership.Role.OWNER, is_active=True, ) self.client.force_authenticate(user=self.admin) admin_response = self.client.patch( f"/api/workspace-memberships/{extra_owner_membership.id}/", {"role": WorkspaceMembership.Role.ADMIN}, format="json", ) self.client.force_authenticate(user=self.owner) owner_response = self.client.patch( f"/api/workspace-memberships/{extra_owner_membership.id}/", {"role": WorkspaceMembership.Role.ADMIN}, format="json", ) self.assertEqual(admin_response.status_code, 403) 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, ) self.client.force_authenticate(user=self.admin) create_response = self.client.post( "/api/workspace-memberships/", { "workspace": str(self.workspace.id), "user": str(self.member.id), "role": WorkspaceMembership.Role.ADMIN, }, format="json", ) update_response = self.client.patch( f"/api/workspace-memberships/{admin_membership.id}/", {"role": WorkspaceMembership.Role.MEMBER}, format="json", ) delete_response = self.client.delete( f"/api/workspace-memberships/{admin_membership.id}/" ) self.assertEqual(create_response.status_code, 403) self.assertEqual(update_response.status_code, 403) self.assertEqual(delete_response.status_code, 403) def test_owner_can_validate_and_commit_member_import_with_rate(self): PriceUnit.objects.create(code="IRT", name="Toman", local_name="Toman", symbol="Toman") target = self._user(21) self.client.force_authenticate(user=self.owner) validate_response = self.client.post( "/api/workspace-memberships/import/validate/", { "workspace": str(self.workspace.id), "rows": [ { "line": 2, "mobile": target.mobile, "role": "member", "hourly_rate": "150000", "currency": "IRT", } ], }, format="json", ) self.assertEqual(validate_response.status_code, 200) self.assertTrue(validate_response.data["can_commit"]) self.assertEqual(validate_response.data["summary"]["valid"], 1) self.assertTrue(validate_response.data["import_token"]) commit_response = self.client.post( "/api/workspace-memberships/import/commit/", { "workspace": str(self.workspace.id), "import_token": validate_response.data["import_token"], }, format="json", ) self.assertEqual(commit_response.status_code, 201) self.assertEqual(commit_response.data["created_memberships"], 1) self.assertEqual(commit_response.data["created_or_updated_rates"], 1) self.assertTrue( WorkspaceMembership.objects.filter( workspace=self.workspace, user=target, role=WorkspaceMembership.Role.MEMBER, is_active=True, ).exists() ) rate = WorkspaceUserRate.objects.get(workspace=self.workspace, user=target) self.assertEqual(rate.hourly_rate, Decimal("150000.00")) self.assertEqual(rate.currency, "IRT") self.assertTrue( HourlyRateHistory.objects.filter( workspace=self.workspace, user=target, hourly_rate=Decimal("150000.00"), currency="IRT", ).exists() ) def test_member_import_rejects_invalid_rows(self): PriceUnit.objects.create(code="USD", name="Dollar", local_name="Dollar", symbol="$") target = self._user(22) self.client.force_authenticate(user=self.owner) response = self.client.post( "/api/workspace-memberships/import/validate/", { "workspace": str(self.workspace.id), "rows": [ {"line": 2, "mobile": "", "role": "member"}, {"line": 3, "mobile": "09120000000", "role": "member"}, {"line": 4, "mobile": target.mobile, "role": "owner"}, {"line": 5, "mobile": target.mobile, "role": "member"}, {"line": 6, "mobile": self.member.mobile, "role": "member"}, {"line": 7, "mobile": self.guest.mobile, "role": "guest", "hourly_rate": "10"}, {"line": 8, "mobile": self.admin.mobile, "role": "admin", "hourly_rate": "0", "currency": "USD"}, {"line": 9, "mobile": self.extra_owner.mobile, "role": "guest", "hourly_rate": "10", "currency": "XXX"}, ], }, format="json", ) self.assertEqual(response.status_code, 200) self.assertFalse(response.data["can_commit"]) self.assertIsNone(response.data["import_token"]) self.assertEqual(response.data["summary"]["invalid"], 8) def test_admin_import_follows_role_assignment_rules(self): target = self._user(23) self.client.force_authenticate(user=self.admin) response = self.client.post( "/api/workspace-memberships/import/validate/", { "workspace": str(self.workspace.id), "rows": [{"line": 2, "mobile": target.mobile, "role": "admin"}], }, format="json", ) self.assertEqual(response.status_code, 200) self.assertFalse(response.data["can_commit"]) self.assertIn("permission", response.data["rows"][0]["messages"][0].lower()) def test_member_cannot_import_workspace_members(self): target = self._user(24) self.client.force_authenticate(user=self.member) response = self.client.post( "/api/workspace-memberships/import/validate/", { "workspace": str(self.workspace.id), "rows": [{"line": 2, "mobile": target.mobile, "role": "member"}], }, format="json", ) self.assertEqual(response.status_code, 403) def test_import_commit_rejects_expired_token(self): self.client.force_authenticate(user=self.owner) response = self.client.post( "/api/workspace-memberships/import/commit/", { "workspace": str(self.workspace.id), "import_token": "invalid-token", }, format="json", ) self.assertEqual(response.status_code, 400) def test_admin_can_delete_only_owned_clients_tags_and_projects(self): self.client.force_authenticate(user=self.owner) owner_client_response = self.client.post( "/api/clients/", { "workspace_id": str(self.workspace.id), "name": "Owner Client", "notes": "", }, format="json", ) owner_tag_response = self.client.post( "/api/tags/", { "workspace_id": str(self.workspace.id), "name": "Owner Tag", "color": "#123456", }, format="json", ) owner_project_response = self.client.post( "/api/projects/", { "workspace": str(self.workspace.id), "name": "Owner Project", "description": "", "client": None, }, format="json", ) self.client.force_authenticate(user=self.admin) admin_client_response = self.client.post( "/api/clients/", { "workspace_id": str(self.workspace.id), "name": "Admin Client", "notes": "", }, format="json", ) admin_tag_response = self.client.post( "/api/tags/", { "workspace_id": str(self.workspace.id), "name": "Admin Tag", "color": "#654321", }, format="json", ) admin_project_response = self.client.post( "/api/projects/", { "workspace": str(self.workspace.id), "name": "Admin Project", "description": "", "client": None, }, format="json", ) delete_owner_client = self.client.delete( f"/api/clients/{owner_client_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 = self.client.delete( f"/api/clients/{admin_client_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']}/" ) self.assertEqual(delete_owner_client.status_code, 403) self.assertEqual(delete_owner_tag.status_code, 403) self.assertIn(delete_owner_project.status_code, {403, 404}) self.assertEqual(delete_admin_client.status_code, 204) self.assertEqual(delete_admin_tag.status_code, 204) self.assertEqual(delete_admin_project.status_code, 204)