"""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"