264 lines
10 KiB
Python
264 lines
10 KiB
Python
from datetime import datetime, timedelta
|
|
|
|
from django.utils import timezone
|
|
from rest_framework.test import APITestCase
|
|
|
|
from apps.projects.models import Project, ProjectAccess
|
|
from apps.tags.models import Tag
|
|
from apps.time_entries.models import TimeEntry
|
|
from apps.users.models import User
|
|
from apps.workspaces.models import Workspace, WorkspaceMembership
|
|
|
|
|
|
def make_aware(year, month, day, hour=9, minute=0, second=0):
|
|
current_timezone = timezone.get_current_timezone()
|
|
return timezone.make_aware(
|
|
datetime(year, month, day, hour, minute, second),
|
|
current_timezone,
|
|
)
|
|
|
|
|
|
class TimeEntryViewTests(APITestCase):
|
|
def test_create_running_time_entry_without_start_time_uses_server_time(self):
|
|
user = User.objects.create_user(mobile="09125555555", password="secret123")
|
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
|
|
|
self.client.force_authenticate(user=user)
|
|
before = timezone.now()
|
|
response = self.client.post(
|
|
"/api/time-entries/",
|
|
{
|
|
"workspace_id": str(workspace.id),
|
|
"description": "Running work",
|
|
},
|
|
format="json",
|
|
)
|
|
after = timezone.now()
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
entry = TimeEntry.objects.get(id=response.data["id"])
|
|
self.assertIsNone(entry.end_time)
|
|
self.assertGreaterEqual(entry.start_time, before)
|
|
self.assertLessEqual(entry.start_time, after)
|
|
self.assertIsInstance(response.data["start_time_ms"], int)
|
|
self.assertIsNone(response.data["end_time_ms"])
|
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
|
|
|
def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
|
|
user = User.objects.create_user(mobile="09126666666", password="secret123")
|
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
|
|
|
first_entry = TimeEntry.objects.create(
|
|
workspace=workspace,
|
|
user=user,
|
|
description="Morning work",
|
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
|
)
|
|
TimeEntry.objects.create(
|
|
workspace=workspace,
|
|
user=user,
|
|
description="Running work",
|
|
start_time=make_aware(2026, 4, 24, 11, 0, 0),
|
|
)
|
|
|
|
self.client.force_authenticate(user=user)
|
|
response = self.client.get(
|
|
"/api/time-entries/",
|
|
{
|
|
"workspace": str(workspace.id),
|
|
"status": "ended",
|
|
"limit": 10,
|
|
"offset": 0,
|
|
},
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.data["current_page_items_count"], 1)
|
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
|
self.assertIn("server_now", response.data)
|
|
self.assertFalse(response.data["has_more"])
|
|
self.assertEqual(len(response.data["groups"]), 1)
|
|
self.assertEqual(len(response.data["groups"][0]["days"]), 1)
|
|
self.assertEqual(
|
|
response.data["groups"][0]["days"][0]["entries"][0]["id"],
|
|
str(first_entry.id),
|
|
)
|
|
entry_payload = response.data["groups"][0]["days"][0]["entries"][0]
|
|
self.assertIsInstance(entry_payload["start_time_ms"], int)
|
|
self.assertIsInstance(entry_payload["end_time_ms"], int)
|
|
self.assertIsInstance(entry_payload["server_now_ms"], int)
|
|
|
|
def test_debug_time_returns_server_clock_payload(self):
|
|
user = User.objects.create_user(mobile="09126666667", password="secret123")
|
|
self.client.force_authenticate(user=user)
|
|
|
|
response = self.client.get("/api/time-entries/debug-time/")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
|
self.assertIn("server_now", response.data)
|
|
|
|
def test_stop_running_time_entry_returns_server_epoch_fields(self):
|
|
user = User.objects.create_user(mobile="09126666668", password="secret123")
|
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
|
entry = TimeEntry.objects.create(
|
|
workspace=workspace,
|
|
user=user,
|
|
description="Running work",
|
|
start_time=timezone.now() - timedelta(seconds=5),
|
|
)
|
|
|
|
self.client.force_authenticate(user=user)
|
|
response = self.client.post(f"/api/time-entries/{entry.id}/stop/", {}, format="json")
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIsInstance(response.data["start_time_ms"], int)
|
|
self.assertIsInstance(response.data["end_time_ms"], int)
|
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
|
entry.refresh_from_db()
|
|
self.assertIsNotNone(entry.duration)
|
|
self.assertGreaterEqual(entry.duration.total_seconds(), 5)
|
|
|
|
def test_time_entry_update_preserves_current_deleted_tags(self):
|
|
user = User.objects.create_user(mobile="09127777777", password="secret123")
|
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
|
tag = Tag.objects.create(workspace=workspace, name="Legacy Tag", color="#475569")
|
|
entry = TimeEntry.objects.create(
|
|
workspace=workspace,
|
|
user=user,
|
|
description="Old",
|
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
|
)
|
|
entry.tags.set([tag])
|
|
tag.delete()
|
|
|
|
self.client.force_authenticate(user=user)
|
|
response = self.client.patch(
|
|
f"/api/time-entries/{entry.id}/",
|
|
{
|
|
"description": "Still editable",
|
|
"tags": [str(tag.id)],
|
|
},
|
|
format="json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.data["description"], "Still editable")
|
|
self.assertTrue(response.data["tag_details"][0]["is_deleted"])
|
|
|
|
def test_time_entry_update_rejects_new_deleted_tag_attachment(self):
|
|
user = User.objects.create_user(mobile="09128888888", password="secret123")
|
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
|
deleted_tag = Tag.objects.create(
|
|
workspace=workspace,
|
|
name="Deleted tag",
|
|
color="#475569",
|
|
)
|
|
deleted_tag.delete()
|
|
entry = TimeEntry.objects.create(
|
|
workspace=workspace,
|
|
user=user,
|
|
description="Entry",
|
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
|
)
|
|
|
|
self.client.force_authenticate(user=user)
|
|
response = self.client.patch(
|
|
f"/api/time-entries/{entry.id}/",
|
|
{"tags": [str(deleted_tag.id)]},
|
|
format="json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertIn("unavailable", response.data["error"].lower())
|
|
|
|
def test_time_entry_update_can_remove_current_deleted_tag(self):
|
|
user = User.objects.create_user(mobile="09129999999", password="secret123")
|
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
|
deleted_tag = Tag.objects.create(
|
|
workspace=workspace,
|
|
name="Deleted tag",
|
|
color="#475569",
|
|
)
|
|
entry = TimeEntry.objects.create(
|
|
workspace=workspace,
|
|
user=user,
|
|
description="Entry",
|
|
start_time=make_aware(2026, 4, 24, 9, 0, 0),
|
|
end_time=make_aware(2026, 4, 24, 10, 30, 0),
|
|
)
|
|
entry.tags.set([deleted_tag])
|
|
deleted_tag.delete()
|
|
|
|
self.client.force_authenticate(user=user)
|
|
response = self.client.patch(
|
|
f"/api/time-entries/{entry.id}/",
|
|
{"tags": []},
|
|
format="json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.data["tags"], [])
|
|
|
|
def test_member_cannot_create_time_entry_for_inaccessible_project(self):
|
|
owner = User.objects.create_user(mobile="09120000001", password="secret123")
|
|
member = User.objects.create_user(mobile="09120000002", password="secret123")
|
|
workspace = Workspace.objects.create(name="Core", owner=owner)
|
|
WorkspaceMembership.objects.create(
|
|
workspace=workspace,
|
|
user=member,
|
|
role=WorkspaceMembership.Role.MEMBER,
|
|
is_active=True,
|
|
)
|
|
project = Project.objects.create(workspace=workspace, name="Restricted")
|
|
|
|
self.client.force_authenticate(user=member)
|
|
response = self.client.post(
|
|
"/api/time-entries/",
|
|
{
|
|
"workspace_id": str(workspace.id),
|
|
"project_id": str(project.id),
|
|
"description": "Blocked",
|
|
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
|
|
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
|
|
},
|
|
format="json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 400)
|
|
self.assertTrue(
|
|
any("Selected project is unavailable." in item["message"] for item in response.data["messages"])
|
|
)
|
|
|
|
def test_member_can_create_time_entry_after_project_access_is_granted(self):
|
|
owner = User.objects.create_user(mobile="09120000011", password="secret123")
|
|
member = User.objects.create_user(mobile="09120000012", password="secret123")
|
|
workspace = Workspace.objects.create(name="Core", owner=owner)
|
|
WorkspaceMembership.objects.create(
|
|
workspace=workspace,
|
|
user=member,
|
|
role=WorkspaceMembership.Role.MEMBER,
|
|
is_active=True,
|
|
)
|
|
project = Project.objects.create(workspace=workspace, name="Accessible")
|
|
ProjectAccess.objects.create(project=project, user=member)
|
|
|
|
self.client.force_authenticate(user=member)
|
|
response = self.client.post(
|
|
"/api/time-entries/",
|
|
{
|
|
"workspace_id": str(workspace.id),
|
|
"project_id": str(project.id),
|
|
"description": "Allowed",
|
|
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
|
|
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
|
|
},
|
|
format="json",
|
|
)
|
|
|
|
self.assertEqual(response.status_code, 201)
|
|
self.assertEqual(response.data["project"], str(project.id))
|