Files
qlockify-backend-deployment/apps/reports/tests/test_views.py

485 lines
17 KiB
Python

from datetime import date, timedelta
from decimal import Decimal
from unittest.mock import patch
from django.core.cache import cache
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.time_entries.models import TimeEntry
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
class ReportViewTests(APITestCase):
@classmethod
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",
)
entry_owner = TimeEntry.objects.create(
workspace=cls.workspace,
user=cls.owner,
project=cls.project,
description="Owner work",
start_time="2026-04-10T08:00:00+03:30",
end_time="2026-04-10T10:00:00+03:30",
duration=timedelta(hours=2),
is_billable=True,
hourly_rate=Decimal("25.00"),
currency="USD",
)
entry_owner.tags.add(cls.tag)
entry_member = TimeEntry.objects.create(
workspace=cls.workspace,
user=cls.member,
project=cls.project,
description="Member work",
start_time="2026-04-11T09:00:00+03:30",
end_time="2026-04-11T10:00:00+03:30",
duration=timedelta(hours=1),
is_billable=False,
currency="USD",
)
entry_member.tags.add(cls.tag)
def setUp(self):
cache.clear()
def test_member_only_sees_own_chart_report(self):
self.client.force_authenticate(user=self.member)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/chart/",
{"workspace": str(self.workspace.id), "period": "this_month"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "01:00:00")
self.assertEqual(len(response.data["series"]), 1)
self.assertEqual(response.data["series"][0]["user"]["id"], str(self.member.id))
def test_admin_chart_without_user_filter_returns_series_for_all_users(self):
self.client.force_authenticate(user=self.admin)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/chart/",
{"workspace": str(self.workspace.id), "period": "this_month"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
self.assertEqual(len(response.data["series"]), 2)
self.assertEqual(
{series["user"]["id"] for series in response.data["series"]},
{str(self.owner.id), str(self.member.id)},
)
def test_admin_can_request_combined_table_report(self):
self.client.force_authenticate(user=self.admin)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{"workspace": str(self.workspace.id), "period": "this_month"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
self.assertEqual(len(response.data["days"]), 2)
self.assertEqual(len(response.data["user_summaries"]), 2)
self.assertIsNone(response.data["days"][0]["latest_hourly_rate"])
self.assertIsNone(response.data["days"][1]["latest_hourly_rate"])
summaries = {item["user"]["id"]: item for item in response.data["user_summaries"]}
owner_summary = summaries[str(self.owner.id)]
member_summary = summaries[str(self.member.id)]
self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100")
self.assertEqual(member_summary["project_percentages"][0]["percentage"], "0")
self.assertEqual(member_summary["client_percentages"][0]["percentage"], "0")
self.assertEqual(member_summary["tag_percentages"][0]["percentage"], "0")
def test_specific_user_report_includes_uncategorized_rows_and_balanced_percentages(self):
self.client.force_authenticate(user=self.owner)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=None,
description="Uncategorized billable",
start_time="2026-04-12T10:00:00+03:30",
end_time="2026-04-12T11:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("10.00"),
currency="USD",
)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
"language": "en",
},
)
self.assertEqual(response.status_code, 200)
summary = response.data["user_summary"]
self.assertEqual(
sum(int(row["percentage"]) for row in summary["project_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["client_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["tag_percentages"]),
100,
)
self.assertEqual(
{row["name"] for row in summary["project_percentages"]},
{"Website", "No project"},
)
self.assertEqual(
{row["name"] for row in summary["client_percentages"]},
{"Acme", "No client"},
)
self.assertEqual(
{row["name"] for row in summary["tag_percentages"]},
{"Design", "No tag"},
)
self.assertEqual(
{row["name"] for row in response.data["projects"]},
{"Website", "No project"},
)
self.assertEqual(
{row["name"] for row in response.data["clients"]},
{"Acme", "No client"},
)
self.assertEqual(
{row["name"] for row in response.data["tags"]},
{"Design", "No tag"},
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["project_income_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["client_income_percentages"]),
100,
)
self.assertEqual(
sum(int(row["percentage"]) for row in summary["tag_income_percentages"]),
100,
)
def test_income_percentages_are_hidden_for_mixed_currency_breakdowns(self):
self.client.force_authenticate(user=self.owner)
second_project = Project.objects.create(
workspace=self.workspace,
name="Mobile App",
client=self.client_obj,
)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=second_project,
description="EUR work",
start_time="2026-04-13T10:00:00+03:30",
end_time="2026-04-13T11:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("20.00"),
currency="EUR",
)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
"language": "en",
},
)
self.assertEqual(response.status_code, 200)
summary = response.data["user_summary"]
self.assertEqual(summary["project_income_percentages"], [])
self.assertEqual(summary["client_income_percentages"], [])
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
self.client.force_authenticate(user=self.owner)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Morning work",
start_time="2026-04-15T08:00:00+03:30",
end_time="2026-04-15T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("20.00"),
currency="USD",
)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Later work",
start_time="2026-04-15T13:00:00+03:30",
end_time="2026-04-15T15:00:00+03:30",
duration=timedelta(hours=2),
is_billable=True,
hourly_rate=Decimal("35.00"),
currency="USD",
)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
},
)
self.assertEqual(response.status_code, 200)
target_day = next(day for day in response.data["days"] if day["date"] == "2026-04-15")
self.assertEqual(
target_day["latest_hourly_rate"],
{"amount": "35.00", "currency": "USD"},
)
def test_user_summary_endpoint_keeps_workspace_rate_history_and_marks_current_row_open(self):
self.client.force_authenticate(user=self.owner)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=None,
description="Legacy workspace rate",
start_time="2026-04-08T08:00:00+03:30",
end_time="2026-04-08T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=True,
hourly_rate=Decimal("12.00"),
currency="USD",
)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Current project rate",
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("25.00"),
currency="USD",
)
WorkspaceUserRate.objects.create(
workspace=self.workspace,
user=self.owner,
hourly_rate=Decimal("12.00"),
currency="USD",
effective_from="2026-04-01T00:00:00+03:30",
is_active=True,
)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/user-summary/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
},
)
self.assertEqual(response.status_code, 200)
rate_periods = response.data["user_summary"]["rate_periods"]
self.assertEqual(
rate_periods,
[
{
"amount": "12.00",
"currency": "USD",
"from_date": "2026-04-08",
"to_date": None,
},
{
"amount": "25.00",
"currency": "USD",
"from_date": "2026-04-10",
"to_date": "2026-04-12",
},
],
)
def test_custom_period_longer_than_31_days_is_rejected(self):
self.client.force_authenticate(user=self.owner)
response = self.client.get(
"/api/reports/chart/",
{
"workspace": str(self.workspace.id),
"period": "period",
"from_date": "2026-01-01",
"to_date": "2026-02-15",
},
)
self.assertEqual(response.status_code, 400)
def test_persian_this_month_uses_jalali_month_bounds(self):
self.client.force_authenticate(user=self.owner)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 27),
):
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Previous jalali month",
start_time="2026-04-20T08:00:00+03:30",
end_time="2026-04-20T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=False,
currency="USD",
)
TimeEntry.objects.create(
workspace=self.workspace,
user=self.owner,
project=self.project,
description="Current jalali month",
start_time="2026-04-21T08:00:00+03:30",
end_time="2026-04-21T10:00:00+03:30",
duration=timedelta(hours=2),
is_billable=False,
currency="USD",
)
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"language": "fa",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "02:00:00")
self.assertEqual(response.data["scope"]["from_date"], "2026-04-21")
def test_table_report_cache_stays_until_time_entry_invalidation(self):
self.client.force_authenticate(user=self.owner)
url = "/api/reports/table/"
params = {"workspace": str(self.workspace.id), "period": "this_month"}
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
first_response = self.client.get(url, params)
self.assertEqual(first_response.status_code, 200)
self.assertEqual(first_response.data["summary"]["total_duration"], "03:00:00")
member_entry = TimeEntry.objects.get(description="Member work")
TimeEntry.objects.filter(id=member_entry.id).update(duration=timedelta(hours=5))
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
cached_response = self.client.get(url, params)
self.assertEqual(cached_response.status_code, 200)
self.assertEqual(cached_response.data["summary"]["total_duration"], "03:00:00")
member_entry.refresh_from_db()
member_entry.description = "Member work updated"
member_entry.save(update_fields=["description"])
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
fresh_response = self.client.get(url, params)
self.assertEqual(fresh_response.status_code, 200)
self.assertEqual(fresh_response.data["summary"]["total_duration"], "07:00:00")