mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-07 20:20:30 -06:00
- Normalize line endings from CRLF to LF across all files to match .editorconfig - Standardize quote style from single quotes to double quotes - Normalize whitespace and formatting throughout codebase - Apply consistent code style across 372 files including: * Application code (models, routes, services, utils) * Test files * Configuration files * CI/CD workflows This ensures consistency with the project's .editorconfig settings and improves code maintainability.
477 lines
17 KiB
Python
477 lines
17 KiB
Python
"""Tests for REST API v1"""
|
|
|
|
import pytest
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
from app import create_app, db
|
|
from app.models import User, Project, TimeEntry, Task, Client, ApiToken
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
"""Create and configure a test app instance"""
|
|
app = create_app(
|
|
{
|
|
"TESTING": True,
|
|
# Use a file-based SQLite DB to ensure consistent connection across contexts
|
|
"SQLALCHEMY_DATABASE_URI": "sqlite:///test_api_v1.sqlite",
|
|
"WTF_CSRF_ENABLED": False,
|
|
}
|
|
)
|
|
|
|
with app.app_context():
|
|
db.create_all()
|
|
yield app
|
|
db.session.remove()
|
|
db.drop_all()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(app):
|
|
"""Test client"""
|
|
return app.test_client()
|
|
|
|
|
|
@pytest.fixture
|
|
def test_user(app):
|
|
"""Create a test user and return its ID"""
|
|
user = User(username="testuser", email="test@example.com")
|
|
user.set_password("password")
|
|
user.is_active = True
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
# Re-query to avoid relying on possibly expired instance state
|
|
uid = db.session.query(User.id).filter_by(username="testuser").scalar()
|
|
return int(uid)
|
|
|
|
|
|
@pytest.fixture
|
|
def admin_user(app):
|
|
"""Create an admin user"""
|
|
user = User(username="admin", email="admin@example.com", role="admin")
|
|
user.set_password("password")
|
|
user.is_active = True
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def api_token(app, test_user):
|
|
"""Create an API token with full permissions"""
|
|
with app.app_context():
|
|
# Robustly resolve user_id even if the instance is expired/detached
|
|
try:
|
|
user_id = int(getattr(test_user, "id"))
|
|
except Exception:
|
|
user = User.query.filter_by(username="testuser").first()
|
|
user_id = int(user.id) if user else None
|
|
token, plain_token = ApiToken.create_token(
|
|
user_id=user_id,
|
|
name="Test Token",
|
|
description="For testing",
|
|
scopes="read:projects,write:projects,read:time_entries,write:time_entries,read:tasks,write:tasks,read:clients,write:clients,read:reports,read:users",
|
|
)
|
|
db.session.add(token)
|
|
db.session.commit()
|
|
return plain_token
|
|
|
|
|
|
@pytest.fixture
|
|
def test_project(app, test_user, test_client_model):
|
|
"""Create a test project"""
|
|
project = Project(
|
|
name="Test Project",
|
|
description="A test project",
|
|
hourly_rate=75.0,
|
|
status="active",
|
|
client_id=test_client_model.id,
|
|
)
|
|
db.session.add(project)
|
|
db.session.commit()
|
|
return project
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client_model(app):
|
|
"""Create a test client"""
|
|
client_model = Client(name="Test Client", email="client@example.com", company="Test Company")
|
|
db.session.add(client_model)
|
|
db.session.commit()
|
|
return client_model
|
|
|
|
|
|
class TestAPIAuthentication:
|
|
"""Test API authentication"""
|
|
|
|
def test_no_token(self, client):
|
|
"""Test request without token"""
|
|
response = client.get("/api/v1/projects")
|
|
assert response.status_code == 401
|
|
data = json.loads(response.data)
|
|
assert "error" in data
|
|
|
|
def test_invalid_token(self, client):
|
|
"""Test request with invalid token"""
|
|
headers = {"Authorization": "Bearer invalid_token"}
|
|
response = client.get("/api/v1/projects", headers=headers)
|
|
assert response.status_code == 401
|
|
|
|
def test_valid_bearer_token(self, client, api_token):
|
|
"""Test request with valid Bearer token"""
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.get("/api/v1/projects", headers=headers)
|
|
assert response.status_code == 200
|
|
|
|
def test_valid_api_key_header(self, client, api_token):
|
|
"""Test request with valid X-API-Key header"""
|
|
headers = {"X-API-Key": api_token}
|
|
response = client.get("/api/v1/projects", headers=headers)
|
|
assert response.status_code == 200
|
|
|
|
def test_insufficient_scope(self, app, client, test_user):
|
|
"""Test request with insufficient scope"""
|
|
# Create token with limited scope
|
|
token, plain_token = ApiToken.create_token(
|
|
user_id=int(test_user), name="Limited Token", scopes="read:projects" # Only read access
|
|
)
|
|
db.session.add(token)
|
|
db.session.commit()
|
|
|
|
headers = {"Authorization": f"Bearer {plain_token}"}
|
|
|
|
# Should work for read
|
|
response = client.get("/api/v1/projects", headers=headers)
|
|
assert response.status_code == 200
|
|
|
|
# Should fail for write
|
|
response = client.post("/api/v1/projects", json={"name": "New Project"}, headers=headers)
|
|
assert response.status_code == 403
|
|
data = json.loads(response.data)
|
|
assert "Insufficient permissions" in data["error"]
|
|
|
|
|
|
class TestProjects:
|
|
"""Test project endpoints"""
|
|
|
|
def test_list_projects(self, client, api_token, test_project):
|
|
"""Test listing projects"""
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.get("/api/v1/projects", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert "projects" in data
|
|
assert "pagination" in data
|
|
assert len(data["projects"]) == 1
|
|
assert data["projects"][0]["name"] == "Test Project"
|
|
|
|
def test_get_project(self, client, api_token, test_project):
|
|
"""Test getting a single project"""
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.get(f"/api/v1/projects/{test_project.id}", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert "project" in data
|
|
assert data["project"]["name"] == "Test Project"
|
|
|
|
@pytest.mark.skip(reason="API endpoint returning 500 - needs investigation")
|
|
def test_create_project(self, client, api_token):
|
|
"""Test creating a project"""
|
|
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
|
|
project_data = {"name": "New Project", "description": "A new project", "hourly_rate": 100.0, "status": "active"}
|
|
|
|
response = client.post("/api/v1/projects", json=project_data, headers=headers)
|
|
|
|
assert response.status_code == 201
|
|
data = json.loads(response.data)
|
|
assert "project" in data
|
|
assert data["project"]["name"] == "New Project"
|
|
|
|
def test_update_project(self, client, api_token, test_project):
|
|
"""Test updating a project"""
|
|
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
|
|
update_data = {"name": "Updated Project", "hourly_rate": 150.0}
|
|
|
|
response = client.put(f"/api/v1/projects/{test_project.id}", json=update_data, headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert data["project"]["name"] == "Updated Project"
|
|
assert data["project"]["hourly_rate"] == 150.0
|
|
|
|
def test_delete_project(self, client, api_token, test_project):
|
|
"""Test archiving a project"""
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.delete(f"/api/v1/projects/{test_project.id}", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
|
|
# Verify project is archived
|
|
# Ensure we don't read a stale instance from the identity map
|
|
db.session.expire_all()
|
|
project = Project.query.get(test_project.id)
|
|
assert project.status == "archived"
|
|
|
|
|
|
class TestTimeEntries:
|
|
"""Test time entry endpoints"""
|
|
|
|
@pytest.mark.skip(reason="Transaction closed error - needs investigation")
|
|
def test_list_time_entries(self, client, api_token, test_user, test_project):
|
|
"""Test listing time entries"""
|
|
# Create a test time entry
|
|
from factories import TimeEntryFactory
|
|
|
|
entry = TimeEntryFactory(
|
|
user_id=int(test_user),
|
|
project_id=test_project.id,
|
|
start_time=datetime.utcnow() - timedelta(hours=2),
|
|
end_time=datetime.utcnow(),
|
|
source="api",
|
|
)
|
|
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.get("/api/v1/time-entries", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert "time_entries" in data
|
|
assert len(data["time_entries"]) == 1
|
|
|
|
def test_create_time_entry(self, client, api_token, test_project):
|
|
"""Test creating a time entry"""
|
|
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
|
|
entry_data = {
|
|
"project_id": test_project.id,
|
|
"start_time": "2024-01-15T09:00:00Z",
|
|
"end_time": "2024-01-15T17:00:00Z",
|
|
"notes": "Development work",
|
|
"billable": True,
|
|
}
|
|
|
|
response = client.post("/api/v1/time-entries", json=entry_data, headers=headers)
|
|
|
|
assert response.status_code == 201
|
|
data = json.loads(response.data)
|
|
assert "time_entry" in data
|
|
assert data["time_entry"]["notes"] == "Development work"
|
|
|
|
@pytest.mark.skip(reason="Transaction closed error - needs investigation")
|
|
def test_update_time_entry(self, client, api_token, test_user, test_project):
|
|
"""Test updating a time entry"""
|
|
# Create entry
|
|
from factories import TimeEntryFactory
|
|
|
|
entry = TimeEntryFactory(
|
|
user_id=int(test_user),
|
|
project_id=test_project.id,
|
|
start_time=datetime.utcnow() - timedelta(hours=2),
|
|
end_time=datetime.utcnow(),
|
|
notes="Original notes",
|
|
source="api",
|
|
)
|
|
|
|
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
|
|
update_data = {"notes": "Updated notes", "billable": False}
|
|
|
|
response = client.put(f"/api/v1/time-entries/{entry.id}", json=update_data, headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert data["time_entry"]["notes"] == "Updated notes"
|
|
assert data["time_entry"]["billable"] == False
|
|
|
|
|
|
class TestTimer:
|
|
"""Test timer control endpoints"""
|
|
|
|
def test_get_timer_status_no_active(self, client, api_token):
|
|
"""Test getting timer status when no timer is active"""
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.get("/api/v1/timer/status", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert data["active"] == False
|
|
assert data["timer"] is None
|
|
|
|
def test_start_timer(self, client, api_token, test_project):
|
|
"""Test starting a timer"""
|
|
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
|
|
timer_data = {"project_id": test_project.id}
|
|
|
|
response = client.post("/api/v1/timer/start", json=timer_data, headers=headers)
|
|
|
|
assert response.status_code == 201
|
|
data = json.loads(response.data)
|
|
assert "timer" in data
|
|
assert data["timer"]["project_id"] == test_project.id
|
|
|
|
@pytest.mark.skip(reason="Transaction closed error - needs investigation")
|
|
def test_stop_timer(self, client, api_token, test_user, test_project):
|
|
"""Test stopping a timer"""
|
|
# Start a timer
|
|
from factories import TimeEntryFactory
|
|
|
|
timer = TimeEntryFactory(
|
|
user_id=int(test_user),
|
|
project_id=test_project.id,
|
|
start_time=datetime.utcnow(),
|
|
end_time=None,
|
|
source="api",
|
|
)
|
|
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.post("/api/v1/timer/stop", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert "time_entry" in data
|
|
assert data["time_entry"]["end_time"] is not None
|
|
|
|
|
|
class TestTasks:
|
|
"""Test task endpoints"""
|
|
|
|
@pytest.mark.skip(reason="Transaction closed error - needs investigation")
|
|
def test_list_tasks(self, client, api_token, test_project):
|
|
"""Test listing tasks"""
|
|
# Create a test task
|
|
task = Task(name="Test Task", project_id=test_project.id, status="todo", priority=1)
|
|
db.session.add(task)
|
|
db.session.commit()
|
|
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.get("/api/v1/tasks", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert "tasks" in data
|
|
assert len(data["tasks"]) == 1
|
|
|
|
@pytest.mark.skip(reason="API endpoint returning 500 - needs investigation")
|
|
def test_create_task(self, client, api_token, test_project):
|
|
"""Test creating a task"""
|
|
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
|
|
task_data = {
|
|
"name": "New Task",
|
|
"description": "Task description",
|
|
"project_id": test_project.id,
|
|
"status": "todo",
|
|
"priority": 1,
|
|
}
|
|
|
|
response = client.post("/api/v1/tasks", json=task_data, headers=headers)
|
|
|
|
assert response.status_code == 201
|
|
data = json.loads(response.data)
|
|
assert "task" in data
|
|
assert data["task"]["name"] == "New Task"
|
|
|
|
|
|
class TestClients:
|
|
"""Test client endpoints"""
|
|
|
|
def test_list_clients(self, client, api_token, test_client_model):
|
|
"""Test listing clients"""
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.get("/api/v1/clients", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert "clients" in data
|
|
assert len(data["clients"]) == 1
|
|
|
|
def test_create_client(self, client, api_token):
|
|
"""Test creating a client"""
|
|
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
|
|
client_data = {"name": "New Client", "email": "newclient@example.com", "company": "New Company"}
|
|
|
|
response = client.post("/api/v1/clients", json=client_data, headers=headers)
|
|
|
|
assert response.status_code == 201
|
|
data = json.loads(response.data)
|
|
assert "client" in data
|
|
assert data["client"]["name"] == "New Client"
|
|
|
|
|
|
class TestReports:
|
|
"""Test report endpoints"""
|
|
|
|
@pytest.mark.skip(reason="Transaction closed error - needs investigation")
|
|
def test_summary_report(self, client, api_token, test_user, test_project):
|
|
"""Test getting summary report"""
|
|
# Create some time entries
|
|
from factories import TimeEntryFactory
|
|
|
|
entry1 = TimeEntryFactory(
|
|
user_id=int(test_user),
|
|
project_id=test_project.id,
|
|
start_time=datetime.utcnow() - timedelta(hours=10),
|
|
end_time=datetime.utcnow() - timedelta(hours=8),
|
|
source="api",
|
|
)
|
|
entry2 = TimeEntryFactory(
|
|
user_id=int(test_user),
|
|
project_id=test_project.id,
|
|
start_time=datetime.utcnow() - timedelta(hours=5),
|
|
end_time=datetime.utcnow() - timedelta(hours=3),
|
|
billable=True,
|
|
source="api",
|
|
)
|
|
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
response = client.get("/api/v1/reports/summary", headers=headers)
|
|
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert "summary" in data
|
|
assert data["summary"]["total_entries"] == 2
|
|
|
|
|
|
class TestPagination:
|
|
"""Test pagination"""
|
|
|
|
@pytest.mark.skip(reason="IntegrityError - needs investigation")
|
|
def test_pagination_params(self, client, api_token, test_project):
|
|
"""Test pagination parameters"""
|
|
# Create multiple projects
|
|
for i in range(15):
|
|
project = Project(name=f"Project {i}", status="active")
|
|
db.session.add(project)
|
|
db.session.commit()
|
|
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
|
|
# Test per_page
|
|
response = client.get("/api/v1/projects?per_page=5", headers=headers)
|
|
data = json.loads(response.data)
|
|
assert len(data["projects"]) == 5
|
|
assert data["pagination"]["per_page"] == 5
|
|
|
|
# Test page
|
|
response = client.get("/api/v1/projects?page=2&per_page=5", headers=headers)
|
|
data = json.loads(response.data)
|
|
assert data["pagination"]["page"] == 2
|
|
|
|
|
|
class TestSystemEndpoints:
|
|
"""Test system endpoints"""
|
|
|
|
def test_api_info(self, client):
|
|
"""Test API info endpoint (no auth required)"""
|
|
response = client.get("/api/v1/info")
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert "api_version" in data
|
|
assert "endpoints" in data
|
|
|
|
def test_health_check(self, client):
|
|
"""Test health check endpoint (no auth required)"""
|
|
response = client.get("/api/v1/health")
|
|
assert response.status_code == 200
|
|
data = json.loads(response.data)
|
|
assert data["status"] == "healthy"
|