"""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, 'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:', '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""" user = User(username='testuser', email='test@example.com') user.set_password('password') user.is_active = True db.session.add(user) db.session.commit() return user @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""" token, plain_token = ApiToken.create_token( user_id=test_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=test_user.id, 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 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 entry = TimeEntry( user_id=test_user.id, project_id=test_project.id, start_time=datetime.utcnow() - timedelta(hours=2), end_time=datetime.utcnow(), source='api' ) 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' @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 entry = TimeEntry( user_id=test_user.id, project_id=test_project.id, start_time=datetime.utcnow() - timedelta(hours=2), end_time=datetime.utcnow(), notes='Original notes', source='api' ) db.session.add(entry) db.session.commit() 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 timer = TimeEntry( user_id=test_user.id, project_id=test_project.id, start_time=datetime.utcnow(), source='api' ) 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""" @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 entry1 = TimeEntry( user_id=test_user.id, project_id=test_project.id, start_time=datetime.utcnow() - timedelta(hours=10), end_time=datetime.utcnow() - timedelta(hours=8), source='api' ) entry2 = TimeEntry( user_id=test_user.id, project_id=test_project.id, start_time=datetime.utcnow() - timedelta(hours=5), end_time=datetime.utcnow() - 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""" @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'