test(backend): convert existing app suites to unittest

This commit is contained in:
2026-04-30 12:41:54 +03:30
parent 204225dd16
commit 8774a4d4dc
16 changed files with 1785 additions and 1780 deletions

View File

@@ -1,264 +1,111 @@
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from unittest.mock import patch
import pytest
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.test import TestCase
from django.utils import timezone
from openpyxl import load_workbook
from apps.notifications.services import store as notification_store
from apps.reports.models import ReportExportJob
from apps.reports.tasks import cleanup_expired_report_exports_task, generate_report_export_task
from apps.time_entries.models import TimeEntry
from apps.reports.tasks import (
cleanup_expired_report_exports_task,
generate_report_export_task,
)
from apps.users.models import User
from apps.workspaces.models import Workspace
class FakeRedis:
def pipeline(self):
return self
class ReportTaskTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.owner = User.objects.create_user(
mobile="09129990001",
password="secret123",
first_name="Owner",
last_name="User",
)
cls.workspace = Workspace.objects.create(name="Exports", owner=cls.owner)
def zadd(self, *args, **kwargs):
return self
def test_generate_excel_export_marks_job_complete_and_sends_notification(self):
job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(self.workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(self.owner.id),
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
def hset(self, *args, **kwargs):
return self
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}) as build_table_report:
with patch("apps.reports.tasks.build_user_scoped_table_reports", return_value=[]) as build_user_reports:
with patch("apps.reports.tasks.build_excel_report", return_value=b"excel-content") as build_excel_report:
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
generate_report_export_task(str(job.id))
def sadd(self, *args, **kwargs):
return self
job.refresh_from_db()
self.assertEqual(job.status, ReportExportJob.Status.COMPLETED)
self.assertTrue(bool(job.file))
self.assertTrue(default_storage.exists(job.file.name))
build_table_report.assert_called_once()
build_user_reports.assert_called_once()
build_excel_report.assert_called_once()
notify.assert_called_once()
self.assertEqual(notify.call_args.args[0], str(self.owner.id))
self.assertEqual(notify.call_args.args[1]["type"], "report_export_ready")
def execute(self):
return []
def test_generate_pdf_export_failure_marks_job_failed_and_notifies(self):
job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={
"workspace": str(self.workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(self.owner.id),
"client": None,
"project": None,
"tags": [],
"language": "fa",
},
)
def publish(self, *args, **kwargs):
return None
with patch("apps.reports.tasks.build_table_report", return_value={"scope": {}, "summary": {}, "days": [], "clients": [], "projects": [], "tags": []}):
with patch("apps.reports.tasks.build_pdf_report", side_effect=RuntimeError("boom")):
with patch("apps.reports.tasks.RedisNotificationStore.add") as notify:
with self.assertRaises(RuntimeError):
generate_report_export_task(str(job.id))
def zrevrange(self, *args, **kwargs):
return []
job.refresh_from_db()
self.assertEqual(job.status, ReportExportJob.Status.FAILED)
self.assertEqual(job.error_message, "boom")
notify.assert_called_once()
self.assertEqual(notify.call_args.args[1]["type"], "report_export_failed")
def hget(self, *args, **kwargs):
return None
def test_cleanup_expires_and_removes_files(self):
job = ReportExportJob.objects.create(
requesting_user=self.owner,
workspace=self.workspace,
export_type=ReportExportJob.ExportType.EXCEL,
status=ReportExportJob.Status.COMPLETED,
filters={},
expires_at=timezone.now() - timezone.timedelta(days=1),
)
file_name = f"reports/exports/{job.id}-old.xlsx"
job.file.save(file_name, ContentFile(b"old-data"), save=False)
job.save(update_fields=["file", "updated_at"])
def zrem(self, *args, **kwargs):
return 1
removed = cleanup_expired_report_exports_task()
job.refresh_from_db()
def hdel(self, *args, **kwargs):
return 1
def zcard(self, *args, **kwargs):
return 0
def smembers(self, *args, **kwargs):
return set()
def srem(self, *args, **kwargs):
return 1
@pytest.fixture()
def fake_redis(monkeypatch):
redis = FakeRedis()
monkeypatch.setattr(notification_store, "redis_client", redis)
return redis
@pytest.fixture()
def owner(db):
return User.objects.create_user(mobile="09129990001", password="secret123", first_name="Owner", last_name="User")
@pytest.fixture()
def teammate(db):
return User.objects.create_user(mobile="09129990002", password="secret123", first_name="Team", last_name="Mate")
@pytest.fixture()
def workspace(owner, teammate):
workspace = Workspace.objects.create(name="Exports", owner=owner)
workspace.memberships.create(user=teammate, role="member", is_active=True)
return workspace
@pytest.fixture()
def time_entry(workspace, owner):
return TimeEntry.objects.create(
workspace=workspace,
user=owner,
description="Export row",
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("15.00"),
currency="USD",
)
@pytest.fixture()
def teammate_entry(workspace, teammate):
return TimeEntry.objects.create(
workspace=workspace,
user=teammate,
description="Team row",
start_time="2026-04-13T08:00:00+03:30",
end_time="2026-04-13T09:00:00+03:30",
duration=timedelta(hours=1),
is_billable=False,
currency="USD",
)
def test_generate_export_creates_file_and_marks_job_complete(fake_redis, workspace, owner, time_entry):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
assert job.status == ReportExportJob.Status.COMPLETED
assert bool(job.file)
assert default_storage.exists(job.file.name)
def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope(
fake_redis,
workspace,
owner,
teammate,
time_entry,
teammate_entry,
):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": None,
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
workbook = load_workbook(BytesIO(job.file.read()))
assert workbook.sheetnames[0] == "Overall Report"
assert any("Owner User" in sheet for sheet in workbook.sheetnames[1:])
assert any("Team Mate" in sheet for sheet in workbook.sheetnames[1:])
assert len(workbook.sheetnames) == 3
def test_generate_excel_export_includes_daily_rate_column_and_split_user_meta(
fake_redis,
workspace,
owner,
time_entry,
):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"client": None,
"project": None,
"tags": [],
"language": "en",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
workbook = load_workbook(BytesIO(job.file.read()))
worksheet = workbook.active
values = list(worksheet.iter_rows(values_only=True))
assert any(row[:2] == ("User", "Owner User") for row in values if row)
assert any(row[:2] == ("Mobile", "09129990001") for row in values if row)
daily_header = next(row[:6] for row in values if row and row[0] == "Date")
assert daily_header == (
"Date",
"Billable hours",
"Non-billable hours",
"Total hours",
"Hourly rate",
"Income",
)
daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12")
assert daily_row[4] == "15 USD"
def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.PDF,
filters={
"workspace": str(workspace.id),
"period": "this_month",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
"user": str(owner.id),
"client": None,
"project": None,
"tags": [],
"language": "fa",
},
)
generate_report_export_task(str(job.id))
job.refresh_from_db()
assert job.status == ReportExportJob.Status.COMPLETED
assert job.file.read(4) == b"%PDF"
def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner):
job = ReportExportJob.objects.create(
requesting_user=owner,
workspace=workspace,
export_type=ReportExportJob.ExportType.EXCEL,
status=ReportExportJob.Status.COMPLETED,
filters={},
expires_at=timezone.now() - timezone.timedelta(days=1),
)
file_name = f"reports/exports/{job.id}-old.xlsx"
job.file.save(file_name, ContentFile(b"old-data"), save=False)
job.save(update_fields=["file", "updated_at"])
removed = cleanup_expired_report_exports_task()
job.refresh_from_db()
assert removed == 1
assert job.status == ReportExportJob.Status.EXPIRED
assert not default_storage.exists(file_name)
self.assertEqual(removed, 1)
self.assertEqual(job.status, ReportExportJob.Status.EXPIRED)
self.assertFalse(default_storage.exists(file_name))

View File

@@ -1,8 +1,8 @@
from datetime import date, timedelta
from decimal import Decimal
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from rest_framework.test import APITestCase
from apps.clients.models import Client
from apps.projects.models import Project
@@ -12,194 +12,199 @@ from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture()
def api_client():
return APIClient()
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)
@pytest.fixture()
def owner(db):
return User.objects.create_user(mobile="09128880001", password="secret123", first_name="Owner")
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 test_member_only_sees_own_chart_report(self):
self.client.force_authenticate(user=self.member)
@pytest.fixture()
def admin(db):
return User.objects.create_user(mobile="09128880002", password="secret123", first_name="Admin")
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")
@pytest.fixture()
def member(db):
return User.objects.create_user(mobile="09128880003", password="secret123", first_name="Member")
def test_admin_can_request_combined_table_report(self):
self.client.force_authenticate(user=self.admin)
response = self.client.get(
"/api/reports/table/",
{"workspace": str(self.workspace.id), "period": "this_month"},
)
@pytest.fixture()
def workspace(owner, admin, member):
workspace = Workspace.objects.create(name="Reports", owner=owner)
WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True)
WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True)
return workspace
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
self.assertEqual(len(response.data["days"]), 2)
self.assertIsNone(response.data["days"][0]["latest_hourly_rate"])
self.assertIsNone(response.data["days"][1]["latest_hourly_rate"])
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
self.client.force_authenticate(user=self.owner)
@pytest.fixture()
def client(workspace):
return Client.objects.create(workspace=workspace, name="Acme")
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",
)
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"user": str(self.owner.id),
},
)
@pytest.fixture()
def project(workspace, client):
return Project.objects.create(workspace=workspace, name="Website", client=client)
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_custom_period_longer_than_31_days_is_rejected(self):
self.client.force_authenticate(user=self.owner)
@pytest.fixture()
def tag(workspace):
return Tag.objects.create(workspace=workspace, name="Design", color="#ffffff")
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)
@pytest.fixture()
def time_entries(workspace, owner, member, project, tag):
entry_owner = TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=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(tag)
entry_member = TimeEntry.objects.create(
workspace=workspace,
user=member,
project=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(tag)
return [entry_owner, entry_member]
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",
)
def test_member_only_sees_own_chart_report(api_client, member, workspace, time_entries):
api_client.force_authenticate(user=member)
response = self.client.get(
"/api/reports/table/",
{
"workspace": str(self.workspace.id),
"period": "this_month",
"language": "fa",
},
)
response = api_client.get(
"/api/reports/chart/",
{"workspace": str(workspace.id), "period": "this_month"},
)
assert response.status_code == 200
assert response.data["summary"]["total_duration"] == "01:00:00"
def test_admin_can_request_combined_table_report(api_client, admin, workspace, time_entries):
api_client.force_authenticate(user=admin)
response = api_client.get(
"/api/reports/table/",
{"workspace": str(workspace.id), "period": "this_month"},
)
assert response.status_code == 200
assert response.data["summary"]["total_duration"] == "03:00:00"
assert len(response.data["days"]) == 2
assert response.data["days"][0]["latest_hourly_rate"] is None
assert response.data["days"][1]["latest_hourly_rate"] is None
def test_daily_rate_uses_latest_billable_entry_snapshot(api_client, owner, workspace, project):
api_client.force_authenticate(user=owner)
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=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=workspace,
user=owner,
project=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",
)
response = api_client.get(
"/api/reports/table/",
{"workspace": str(workspace.id), "period": "this_month", "user": str(owner.id)},
)
assert response.status_code == 200
assert response.data["days"][0]["latest_hourly_rate"] == {
"amount": "35.00",
"currency": "USD",
}
def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace):
api_client.force_authenticate(user=owner)
response = api_client.get(
"/api/reports/chart/",
{
"workspace": str(workspace.id),
"period": "period",
"from_date": "2026-01-01",
"to_date": "2026-02-15",
},
)
assert response.status_code == 400
def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspace, project, monkeypatch):
api_client.force_authenticate(user=owner)
monkeypatch.setattr("apps.reports.services.aggregation.timezone.localdate", lambda: date(2026, 4, 27))
TimeEntry.objects.create(
workspace=workspace,
user=owner,
project=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=workspace,
user=owner,
project=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 = api_client.get(
"/api/reports/table/",
{"workspace": str(workspace.id), "period": "this_month", "language": "fa"},
)
assert response.status_code == 200
assert response.data["summary"]["total_duration"] == "02:00:00"
assert response.data["scope"]["from_date"] == "2026-04-21"
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")