from datetime import timedelta from decimal import Decimal from django.test import TestCase from django.utils import timezone from rest_framework.exceptions import ValidationError from apps.projects.models import Project, ProjectAccess, ProjectUserRate 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.users.models import User from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate class TimeEntryServiceTests(TestCase): @classmethod def setUpTestData(cls): cls.user = User.objects.create_user(mobile="09121111111", password="secret123") cls.member = User.objects.create_user(mobile="09121111112", password="secret123") cls.workspace = Workspace.objects.create(name="Core", owner=cls.user) WorkspaceMembership.objects.create( workspace=cls.workspace, user=cls.member, role=WorkspaceMembership.Role.MEMBER, is_active=True, ) def test_create_time_entry_allows_only_one_running_timer_per_workspace(self): create_time_entry( user=self.user, workspace_id=self.workspace.id, start_time=timezone.now(), ) with self.assertRaises(ValidationError): create_time_entry( user=self.user, workspace_id=self.workspace.id, start_time=timezone.now() + timedelta(minutes=5), ) def test_stop_time_entry_sets_end_time_and_duration(self): entry = create_time_entry( user=self.user, workspace_id=self.workspace.id, start_time=timezone.now() - timedelta(hours=1), ) stopped_entry = stop_time_entry(entry, end_time=timezone.now()) self.assertIsNotNone(stopped_entry.end_time) self.assertIsNotNone(stopped_entry.duration) def test_create_running_time_entry_defaults_start_time_to_server_now(self): before = timezone.now() entry = create_time_entry( user=self.user, workspace_id=self.workspace.id, ) after = timezone.now() self.assertIsNone(entry.end_time) self.assertGreaterEqual(entry.start_time, before) self.assertLessEqual(entry.start_time, after) def test_update_time_entry_preserves_deleted_project_and_tags(self): project = Project.objects.create(workspace=self.workspace, name="Deleted project") tag = Tag.objects.create( workspace=self.workspace, name="Deleted tag", color="#0f172a", ) entry = create_time_entry( user=self.user, workspace_id=self.workspace.id, start_time=timezone.now() - timedelta(hours=1), end_time=timezone.now(), project=project, tags=[tag], description="Before delete", ) project.delete() tag.delete() updated_entry = update_time_entry( entry, project=Project.all_objects.get(id=project.id), tags=[Tag.all_objects.get(id=tag.id)], description="After delete", ) self.assertEqual(updated_entry.description, "After delete") self.assertEqual(updated_entry.project_id, project.id) self.assertEqual( list( Tag.all_objects.filter(time_entries=updated_entry).values_list( "id", flat=True, ) ), [tag.id], ) def test_create_billable_time_entry_uses_project_user_rate_override(self): project = Project.objects.create(workspace=self.workspace, name="Override project") ProjectAccess.objects.create(project=project, user=self.member) 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, ) ProjectUserRate.objects.create( project=project, user=self.member, hourly_rate=Decimal("20.00"), currency="EUR", effective_from=self.workspace.created_at, is_active=True, ) entry = create_time_entry( user=self.member, workspace_id=self.workspace.id, start_time=timezone.now() - timedelta(minutes=30), end_time=timezone.now(), project=project, description="Billable work", is_billable=True, ) self.assertEqual(entry.hourly_rate, Decimal("20.00")) self.assertEqual(entry.currency, "EUR")