from decimal import Decimal from django.core.cache import cache from django.test import TestCase from rest_framework.test import APITestCase from apps.projects.models import Project, ProjectAccess, ProjectUserRate from apps.time_entries.services.rates import resolve_rate from apps.users.models import User from apps.workspaces.models import ( PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate, ) from apps.workspaces.services.rates import ( update_workspace_user_rate, upsert_workspace_user_rate, ) class WorkspaceRateTests(APITestCase): @classmethod 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") PriceUnit.objects.create( code="USD", name="US Dollar", local_name="Dollar", symbol="$", ) PriceUnit.objects.create( code="EUR", name="Euro", local_name="Euro", symbol="EUR", ) def setUp(self): cache.clear() def test_resolve_rate_uses_workspace_user_rate(self): WorkspaceUserRate.objects.create( workspace=self.workspace, user=self.member, hourly_rate=Decimal("40.00"), currency="EUR", effective_from=self.project.created_at, is_active=True, ) hourly_rate, currency = resolve_rate(self.member, self.project) self.assertEqual(hourly_rate, Decimal("40.00")) 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) self.assertIsNone(hourly_rate) self.assertEqual(currency, "") def test_resolve_rate_prefers_project_user_rate_when_member_has_access(self): WorkspaceUserRate.objects.create( workspace=self.workspace, user=self.member, hourly_rate=Decimal("40.00"), currency="EUR", effective_from=self.project.created_at, is_active=True, ) ProjectAccess.objects.create(project=self.project, user=self.member) ProjectUserRate.objects.create( project=self.project, user=self.member, hourly_rate=Decimal("55.00"), currency="USD", effective_from=self.project.created_at, is_active=True, ) hourly_rate, currency = resolve_rate(self.member, self.project) self.assertEqual(hourly_rate, Decimal("55.00")) self.assertEqual(currency, "USD") def test_resolve_rate_ignores_project_user_rate_without_access(self): WorkspaceUserRate.objects.create( workspace=self.workspace, user=self.member, hourly_rate=Decimal("40.00"), currency="EUR", effective_from=self.project.created_at, is_active=True, ) ProjectUserRate.objects.create( project=self.project, user=self.member, hourly_rate=Decimal("55.00"), currency="USD", effective_from=self.project.created_at, is_active=True, ) hourly_rate, currency = resolve_rate(self.member, self.project) self.assertEqual(hourly_rate, Decimal("40.00")) self.assertEqual(currency, "EUR") def test_admin_can_manage_workspace_user_rates(self): self.client.force_authenticate(user=self.admin) create_response = self.client.post( "/api/workspace-user-rates/", { "workspace_id": str(self.workspace.id), "user_id": str(self.member.id), "hourly_rate": "35.50", "currency": "USD", }, format="json", ) self.assertEqual(create_response.status_code, 201) rate_id = create_response.data["id"] self.assertTrue( WorkspaceUserRate.objects.filter(id=rate_id, is_deleted=False).exists() ) update_response = self.client.patch( f"/api/workspace-user-rates/{rate_id}/", {"hourly_rate": "42.00"}, format="json", ) self.assertEqual(update_response.status_code, 200) self.assertEqual(update_response.data["hourly_rate"], "42.00") delete_response = self.client.delete(f"/api/workspace-user-rates/{rate_id}/") self.assertEqual(delete_response.status_code, 204) 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) response = self.client.post( "/api/workspace-user-rates/", { "workspace_id": str(self.workspace.id), "user_id": str(self.member.id), "hourly_rate": "25.00", "currency": "USD", }, format="json", ) self.assertEqual(response.status_code, 403) def test_workspace_user_rates_cache_invalidates_after_rate_save(self): rate = WorkspaceUserRate.objects.create( workspace=self.workspace, user=self.member, hourly_rate=Decimal("30.00"), currency="USD", effective_from=self.workspace.created_at, is_active=True, ) self.client.force_authenticate(user=self.admin) first_response = self.client.get( "/api/workspace-user-rates/", {"workspace": str(self.workspace.id)}, ) self.assertEqual(first_response.status_code, 200) self.assertEqual(first_response.data["items"][0]["hourly_rate"], "30.00") WorkspaceUserRate.objects.filter(id=rate.id).update(hourly_rate=Decimal("45.00")) cached_response = self.client.get( "/api/workspace-user-rates/", {"workspace": str(self.workspace.id)}, ) self.assertEqual(cached_response.status_code, 200) self.assertEqual(cached_response.data["items"][0]["hourly_rate"], "30.00") rate.refresh_from_db() rate.currency = "EUR" rate.save(update_fields=["currency"]) fresh_response = self.client.get( "/api/workspace-user-rates/", {"workspace": str(self.workspace.id)}, ) self.assertEqual(fresh_response.status_code, 200) self.assertEqual(fresh_response.data["items"][0]["hourly_rate"], "45.00") self.assertEqual(fresh_response.data["items"][0]["currency"], "EUR") def test_price_unit_cache_invalidates_after_price_unit_create(self): self.client.force_authenticate(user=self.owner) first_response = self.client.get("/api/price-units/") self.assertEqual(first_response.status_code, 200) self.assertEqual(first_response.data[0]["name"], "Euro") self.assertEqual(len(first_response.data), 2) PriceUnit.objects.filter(code="EUR").update(name="Updated Euro") cached_response = self.client.get("/api/price-units/") self.assertEqual(cached_response.status_code, 200) self.assertEqual(cached_response.data[0]["name"], "Euro") PriceUnit.objects.create( code="GBP", name="British Pound", local_name="Pound", symbol="£", ) fresh_response = self.client.get("/api/price-units/") self.assertEqual(fresh_response.status_code, 200) self.assertEqual(len(fresh_response.data), 3) euro_row = next(item for item in fresh_response.data if item["code"] == "EUR") self.assertEqual(euro_row["name"], "Updated Euro") 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")