mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
8c2714bec3
- /api/activity: return 400 with clear message when start_date/end_date are not valid ISO 8601; avoid silent pass on parse errors - Web route /activity: catch ValueError, log and skip filter instead of 500 - Add tests for invalid date formats on API and web routes
448 lines
18 KiB
Python
448 lines
18 KiB
Python
"""Tests for Activity Feed functionality"""
|
|
|
|
import pytest
|
|
from datetime import datetime, timedelta
|
|
from app.models import Activity, User, Project, Task, TimeEntry, Client
|
|
from app import db
|
|
|
|
|
|
class TestActivityModel:
|
|
"""Tests for the Activity model"""
|
|
|
|
def test_activity_creation(self, app, test_user, test_project):
|
|
"""Test creating an activity log entry"""
|
|
with app.app_context():
|
|
activity = Activity(
|
|
user_id=test_user.id,
|
|
action="created",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description=f'Created project "{test_project.name}"',
|
|
)
|
|
db.session.add(activity)
|
|
db.session.commit()
|
|
|
|
assert activity.id is not None
|
|
assert activity.user_id == test_user.id
|
|
assert activity.action == "created"
|
|
assert activity.entity_type == "project"
|
|
assert activity.entity_id == test_project.id
|
|
assert activity.created_at is not None
|
|
|
|
def test_activity_log_method(self, app, test_user, test_project):
|
|
"""Test the Activity.log() class method"""
|
|
with app.app_context():
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="updated",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description=f'Updated project "{test_project.name}"',
|
|
extra_data={"field": "name"},
|
|
)
|
|
|
|
activity = Activity.query.filter_by(
|
|
user_id=test_user.id, entity_type="project", entity_id=test_project.id
|
|
).first()
|
|
|
|
assert activity is not None
|
|
assert activity.action == "updated"
|
|
assert activity.extra_data == {"field": "name"}
|
|
|
|
def test_activity_get_recent(self, app, test_user, test_project):
|
|
"""Test getting recent activities"""
|
|
with app.app_context():
|
|
# Create multiple activities
|
|
for i in range(5):
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="updated",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description=f"Action {i}",
|
|
)
|
|
|
|
# Get recent activities
|
|
activities = Activity.get_recent(user_id=test_user.id, limit=3)
|
|
|
|
assert len(activities) == 3
|
|
assert activities[0].description == "Action 4" # Most recent first
|
|
|
|
def test_activity_filter_by_entity_type(self, app, test_user, test_project, test_task):
|
|
"""Test filtering activities by entity type"""
|
|
with app.app_context():
|
|
# Create activities for different entity types
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="created",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description="Project created",
|
|
)
|
|
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="created",
|
|
entity_type="task",
|
|
entity_id=test_task.id,
|
|
entity_name=test_task.name,
|
|
description="Task created",
|
|
)
|
|
|
|
# Filter by entity type
|
|
project_activities = Activity.get_recent(user_id=test_user.id, entity_type="project")
|
|
|
|
task_activities = Activity.get_recent(user_id=test_user.id, entity_type="task")
|
|
|
|
assert len(project_activities) == 1
|
|
assert project_activities[0].entity_type == "project"
|
|
assert len(task_activities) == 1
|
|
assert task_activities[0].entity_type == "task"
|
|
|
|
def test_activity_to_dict(self, app, test_user, test_project):
|
|
"""Test converting activity to dictionary"""
|
|
with app.app_context():
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="created",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description="Test activity",
|
|
)
|
|
|
|
activity = Activity.query.filter_by(user_id=test_user.id).first()
|
|
activity_dict = activity.to_dict()
|
|
|
|
assert activity_dict["id"] == activity.id
|
|
assert activity_dict["user_id"] == test_user.id
|
|
assert activity_dict["action"] == "created"
|
|
assert activity_dict["entity_type"] == "project"
|
|
assert activity_dict["entity_id"] == test_project.id
|
|
assert activity_dict["description"] == "Test activity"
|
|
assert "created_at" in activity_dict
|
|
|
|
def test_activity_get_icon(self, app, test_user, test_project):
|
|
"""Test getting icon for different activity types"""
|
|
with app.app_context():
|
|
actions = ["created", "updated", "deleted", "started", "stopped"]
|
|
|
|
for action in actions:
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action=action,
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description=f"{action} project",
|
|
)
|
|
|
|
activity = Activity.query.filter_by(action=action).first()
|
|
icon = activity.get_icon()
|
|
|
|
assert icon is not None
|
|
assert "fas fa-" in icon
|
|
|
|
|
|
class TestActivityAPIEndpoints:
|
|
"""Tests for Activity Feed API endpoints"""
|
|
|
|
def test_get_activities(self, authenticated_client, test_user, test_project):
|
|
"""Test GET /api/activities endpoint"""
|
|
# Create some test activities
|
|
with authenticated_client.application.app_context():
|
|
for i in range(3):
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="updated",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description=f"Activity {i}",
|
|
)
|
|
|
|
response = authenticated_client.get("/api/activities")
|
|
assert response.status_code == 200
|
|
|
|
data = response.get_json()
|
|
assert "activities" in data
|
|
assert len(data["activities"]) >= 3
|
|
assert "total" in data
|
|
assert "pages" in data
|
|
|
|
def test_get_activities_with_entity_type_filter(self, authenticated_client, test_user, test_project, test_task):
|
|
"""Test filtering activities by entity type"""
|
|
with authenticated_client.application.app_context():
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="created",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description="Project activity",
|
|
)
|
|
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="created",
|
|
entity_type="task",
|
|
entity_id=test_task.id,
|
|
entity_name=test_task.name,
|
|
description="Task activity",
|
|
)
|
|
|
|
# Filter by project entity type
|
|
response = authenticated_client.get("/api/activities?entity_type=project")
|
|
assert response.status_code == 200
|
|
|
|
data = response.get_json()
|
|
assert all(act["entity_type"] == "project" for act in data["activities"])
|
|
|
|
def test_get_activities_with_pagination(self, authenticated_client, test_user, test_project):
|
|
"""Test pagination of activities"""
|
|
with authenticated_client.application.app_context():
|
|
# Create 15 activities
|
|
for i in range(15):
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="updated",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description=f"Activity {i}",
|
|
)
|
|
|
|
# Get first page
|
|
response = authenticated_client.get("/api/activities?limit=5&page=1")
|
|
assert response.status_code == 200
|
|
|
|
data = response.get_json()
|
|
assert len(data["activities"]) == 5
|
|
assert data["has_next"] is True
|
|
|
|
# Get second page
|
|
response = authenticated_client.get("/api/activities?limit=5&page=2")
|
|
assert response.status_code == 200
|
|
|
|
data = response.get_json()
|
|
assert len(data["activities"]) == 5
|
|
|
|
def test_get_activity_stats(self, authenticated_client, test_user, test_project, test_task):
|
|
"""Test GET /api/activities/stats endpoint"""
|
|
with authenticated_client.application.app_context():
|
|
# Create varied activities
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="created",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description="Project created",
|
|
)
|
|
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="updated",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description="Project updated",
|
|
)
|
|
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="created",
|
|
entity_type="task",
|
|
entity_id=test_task.id,
|
|
entity_name=test_task.name,
|
|
description="Task created",
|
|
)
|
|
|
|
response = authenticated_client.get("/api/activities/stats")
|
|
assert response.status_code == 200
|
|
|
|
data = response.get_json()
|
|
assert "total_activities" in data
|
|
assert "entity_counts" in data
|
|
assert "action_counts" in data
|
|
assert data["total_activities"] >= 3
|
|
|
|
|
|
class TestActivityIntegration:
|
|
"""Tests for activity logging integration in routes"""
|
|
|
|
def test_project_create_logs_activity(self, admin_authenticated_client, test_client):
|
|
"""Test that creating a project logs an activity"""
|
|
with admin_authenticated_client.application.app_context():
|
|
# Count activities before
|
|
before_count = Activity.query.count()
|
|
|
|
response = admin_authenticated_client.post(
|
|
"/projects/create",
|
|
data={
|
|
"name": "Test Activity Project",
|
|
"client_id": test_client.id,
|
|
"billable": "on",
|
|
"description": "Test project for activity",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
with admin_authenticated_client.application.app_context():
|
|
# Check activity was logged
|
|
after_count = Activity.query.count()
|
|
assert after_count == before_count + 1
|
|
|
|
activity = Activity.query.order_by(Activity.created_at.desc()).first()
|
|
assert activity.action == "created"
|
|
assert activity.entity_type == "project"
|
|
assert "Test Activity Project" in activity.description
|
|
|
|
def test_task_create_logs_activity(self, authenticated_client, test_project):
|
|
"""Test that creating a task logs an activity"""
|
|
with authenticated_client.application.app_context():
|
|
before_count = Activity.query.count()
|
|
|
|
response = authenticated_client.post(
|
|
"/tasks/create",
|
|
data={
|
|
"project_id": test_project.id,
|
|
"name": "Test Activity Task",
|
|
"priority": "high",
|
|
"description": "Test task for activity",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
with authenticated_client.application.app_context():
|
|
after_count = Activity.query.count()
|
|
assert after_count == before_count + 1
|
|
|
|
activity = Activity.query.order_by(Activity.created_at.desc()).first()
|
|
assert activity.action == "created"
|
|
assert activity.entity_type == "task"
|
|
assert "Test Activity Task" in activity.description
|
|
|
|
def test_timer_start_logs_activity(self, authenticated_client, test_project):
|
|
"""Test that starting a timer logs an activity"""
|
|
with authenticated_client.application.app_context():
|
|
before_count = Activity.query.count()
|
|
|
|
response = authenticated_client.post(
|
|
"/timer/start", data={"project_id": test_project.id, "notes": "Test timer"}, follow_redirects=False
|
|
)
|
|
|
|
with authenticated_client.application.app_context():
|
|
after_count = Activity.query.count()
|
|
assert after_count == before_count + 1
|
|
|
|
activity = Activity.query.order_by(Activity.created_at.desc()).first()
|
|
assert activity.action == "started"
|
|
assert activity.entity_type == "time_entry"
|
|
assert test_project.name in activity.description
|
|
|
|
def test_timer_stop_logs_activity(self, authenticated_client, test_user, test_project):
|
|
"""Test that stopping a timer logs an activity"""
|
|
with authenticated_client.application.app_context():
|
|
# Create an active timer
|
|
from app.models.time_entry import local_now
|
|
|
|
timer = TimeEntry(user_id=test_user.id, project_id=test_project.id, start_time=local_now(), source="auto")
|
|
db.session.add(timer)
|
|
db.session.commit()
|
|
|
|
before_count = Activity.query.count()
|
|
|
|
response = authenticated_client.post("/timer/stop", follow_redirects=False)
|
|
|
|
with authenticated_client.application.app_context():
|
|
after_count = Activity.query.count()
|
|
assert after_count == before_count + 1
|
|
|
|
activity = Activity.query.order_by(Activity.created_at.desc()).first()
|
|
assert activity.action == "stopped"
|
|
assert activity.entity_type == "time_entry"
|
|
assert test_project.name in activity.description
|
|
|
|
|
|
class TestActivityFeedDateParams:
|
|
"""Tests for activity_feed blueprint date parameter validation (/activity and /api/activity).
|
|
Uses session_transaction to set user so requests are authenticated; activity_feed module must be enabled.
|
|
"""
|
|
|
|
def test_api_activity_valid_date_params(self, app, client, user, test_project):
|
|
"""GET /api/activity with valid start_date and end_date returns 200 and applies filter."""
|
|
with client.session_transaction() as sess:
|
|
sess["_user_id"] = str(user.id)
|
|
sess["_fresh"] = True
|
|
with app.app_context():
|
|
Activity.log(
|
|
user_id=user.id,
|
|
action="created",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description="Activity for date filter",
|
|
)
|
|
response = client.get("/api/activity?start_date=2024-01-01&end_date=2025-12-31")
|
|
assert response.status_code == 200
|
|
data = response.get_json()
|
|
assert "activities" in data
|
|
assert "pagination" in data
|
|
|
|
def test_api_activity_invalid_start_date_returns_400(self, app, client, user):
|
|
"""GET /api/activity with invalid start_date returns 400 and error message."""
|
|
with client.session_transaction() as sess:
|
|
sess["_user_id"] = str(user.id)
|
|
sess["_fresh"] = True
|
|
response = client.get("/api/activity?start_date=not-a-date")
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert data.get("error") == "Invalid parameter"
|
|
assert "start_date" in data.get("message", "").lower() or "ISO 8601" in data.get("message", "")
|
|
|
|
def test_api_activity_invalid_end_date_returns_400(self, app, client, user):
|
|
"""GET /api/activity with invalid end_date returns 400."""
|
|
with client.session_transaction() as sess:
|
|
sess["_user_id"] = str(user.id)
|
|
sess["_fresh"] = True
|
|
response = client.get("/api/activity?end_date=invalid")
|
|
assert response.status_code == 400
|
|
data = response.get_json()
|
|
assert "message" in data
|
|
|
|
def test_web_activity_invalid_date_filter_skipped_no_crash(self, app, client, user):
|
|
"""GET /activity (web) with invalid date param: date filter is skipped (no crash in route)."""
|
|
with client.session_transaction() as sess:
|
|
sess["_user_id"] = str(user.id)
|
|
sess["_fresh"] = True
|
|
response = client.get("/activity?start_date=not-a-date")
|
|
# Route must not crash on invalid date (ValueError caught and logged); 500 may be template missing
|
|
assert response.status_code in (200, 500)
|
|
|
|
|
|
class TestActivityWidget:
|
|
"""Tests for the activity feed widget on dashboard"""
|
|
|
|
def test_dashboard_includes_activities(self, authenticated_client, test_user, test_project):
|
|
"""Test that the dashboard includes recent activities"""
|
|
with authenticated_client.application.app_context():
|
|
# Create some activities
|
|
Activity.log(
|
|
user_id=test_user.id,
|
|
action="created",
|
|
entity_type="project",
|
|
entity_id=test_project.id,
|
|
entity_name=test_project.name,
|
|
description="Test activity",
|
|
)
|
|
|
|
response = authenticated_client.get("/dashboard")
|
|
assert response.status_code == 200
|
|
assert b"Recent Activity" in response.data
|
|
assert b"Test activity" in response.data
|