Files
TimeTracker/tests/test_activity_feed.py
T
Dries Peeters 8c2714bec3 fix(activity-feed): validate date params and return 400 for invalid API input
- /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
2026-03-16 16:42:53 +01:00

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