Files
TimeTracker/tests/test_api_v1.py
T
Dries Peeters eb2f5c6afa fix(apps): harden desktop and mobile server connectivity
Desktop (Electron):
- Add two-step first-run wizard: test TimeTracker via GET /api/v1/info, then log in with API token
- Replace bogus token check with validateSession (users/me, fallback to timer/status for narrow scopes)
- Normalize base URLs; classify TLS/DNS/timeout errors; periodic 401 forces re-login
- Settings save/test use public + authenticated checks; prebuild/prestart and npm test

Server:
- Exempt /api/v1/info, /api/v1/health, and POST /api/v1/auth/login from HTML setup redirect
- Include setup_required on GET /api/v1/info for unfinished installs

Mobile (Flutter):
- Validate saved token against new server URL before persisting settings change
- Remove unused lib/core/config.dart; point BUILD_CONFIGURATION at app_config.dart

Docs: DESKTOP_SETTINGS, desktop README, mobile-desktop-apps README, REST_API /info
2026-04-16 19:59:20 +02:00

515 lines
17 KiB
Python

"""Tests for REST API v1"""
import json
import os
import tempfile
import uuid
from datetime import datetime, timedelta
import pytest
from sqlalchemy.pool import NullPool
from app import create_app, db
from app.models import ApiToken, Client, Project, Settings, Task, TimeEntry, User
pytestmark = [pytest.mark.api, pytest.mark.integration]
@pytest.fixture
def app():
"""Create and configure a test app instance (isolated SQLite, same engine options as main conftest)."""
unique_db_path = os.path.join(tempfile.gettempdir(), f"pytest_api_v1_{uuid.uuid4().hex}.sqlite")
app = create_app(
{
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{unique_db_path}",
"SQLALCHEMY_ENGINE_OPTIONS": {
"pool_pre_ping": True,
"connect_args": {"timeout": 30},
"poolclass": NullPool,
},
"WTF_CSRF_ENABLED": False,
"SERVER_NAME": "localhost:5000",
}
)
with app.app_context():
db.create_all()
settings = Settings()
db.session.add(settings)
db.session.commit()
yield app
db.session.remove()
try:
db.drop_all()
except Exception:
pass
try:
db.engine.dispose()
except Exception:
pass
try:
if os.path.exists(unique_db_path):
os.remove(unique_db_path)
except Exception:
pass
@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 (uses app fixture's application context)."""
user_id = int(test_user)
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_client_model):
"""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 (include client_id so we hit scope check, not validation)
response = client.post(
"/api/v1/projects",
json={"name": "New Project", "client_id": test_client_model.id},
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"
def test_create_project(self, client, api_token, test_client_model):
"""Test creating a project"""
headers = {"Authorization": f"Bearer {api_token}", "Content-Type": "application/json"}
project_data = {
"name": "New Project",
"description": "A new project",
"client_id": test_client_model.id,
"hourly_rate": 100.0,
}
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"""
def test_list_time_entries(self, client, api_token, test_user, test_project):
"""Test listing time entries"""
entry = TimeEntry(
user_id=int(test_user),
project_id=test_project.id,
start_time=datetime.utcnow() - timedelta(hours=2),
end_time=datetime.utcnow(),
source="api",
billable=True,
)
db.session.add(entry)
db.session.commit()
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"
def test_update_time_entry(self, client, api_token, test_user, test_project):
"""Test updating a time entry"""
entry = TimeEntry(
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",
billable=True,
)
db.session.add(entry)
db.session.commit()
entry_id = entry.id
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"] is 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
def test_stop_timer(self, client, api_token, test_user, test_project):
"""Test stopping a timer"""
timer = TimeEntry(
user_id=int(test_user),
project_id=test_project.id,
start_time=datetime.utcnow(),
end_time=None,
source="api",
billable=True,
)
db.session.add(timer)
db.session.commit()
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"""
def test_list_tasks(self, client, api_token, test_user, test_project):
"""Test listing tasks"""
task = Task(
name="Test Task",
project_id=test_project.id,
status="todo",
priority="medium",
created_by=int(test_user),
)
db.session.add(task)
db.session.commit()
headers = {"Authorization": f"Bearer {api_token}"}
response = client.get(f"/api/v1/tasks?project_id={test_project.id}", headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert "tasks" in data
assert len(data["tasks"]) == 1
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": "medium",
}
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"""
def test_summary_report(self, client, api_token, test_user, test_project):
"""Test getting summary report"""
now = datetime.utcnow()
entry1 = TimeEntry(
user_id=int(test_user),
project_id=test_project.id,
start_time=now - timedelta(hours=10),
end_time=now - timedelta(hours=8),
source="api",
billable=True,
)
entry2 = TimeEntry(
user_id=int(test_user),
project_id=test_project.id,
start_time=now - timedelta(hours=5),
end_time=now - timedelta(hours=3),
billable=True,
source="api",
)
db.session.add_all([entry1, entry2])
db.session.commit()
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"""
def test_pagination_params(self, client, api_token, test_project, test_client_model):
"""Test pagination parameters"""
for i in range(15):
project = Project(
name=f"Paginate Project {i}",
status="active",
client_id=test_client_model.id,
)
db.session.add(project)
db.session.commit()
headers = {"Authorization": f"Bearer {api_token}"}
# Test per_page (1 from test_project fixture + 15 new = 16 active projects)
response = client.get("/api/v1/projects?per_page=5", headers=headers)
assert response.status_code == 200
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)
assert response.status_code == 200
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
assert "setup_required" in data
assert isinstance(data["setup_required"], bool)
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"