mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-09 05:38:55 -06:00
Major improvements to the backup restore system with a complete UI overhaul and enhanced functionality: UI/UX Improvements: - Complete redesign of restore page with modern Tailwind CSS - Added prominent warning banners and danger badges to prevent accidental data loss - Implemented drag-and-drop file upload with visual feedback - Added real-time progress tracking with auto-refresh every 2 seconds - Added comprehensive safety information sidebar with checklists - Full dark mode support throughout restore interface - Enhanced confirmation flows with checkbox and modal confirmations Functionality Enhancements: - Added dual restore methods: upload new backup or restore from existing server backups - Enhanced restore route to accept optional filename parameter for existing backups - Added "Restore" button to each backup in the backups management page - Implemented restore confirmation modal with critical warnings - Added loading states and button disabling during restore operations - Improved error handling and user feedback Backend Changes: - Enhanced admin.restore() to support both file upload and existing backup restore - Added dual route support: /admin/restore and /admin/restore/<filename> - Added shutil import for file copy operations during restore - Improved security with secure_filename validation and file type checking - Maintained existing rate limiting (3 requests per minute) Frontend Improvements: - Added interactive JavaScript for file selection, drag-and-drop, and modal management - Implemented auto-refresh during restore process to show live progress - Added escape key support for closing modals - Enhanced user feedback with file name display and button states Safety Features: - Pre-restore checklist with 5 verification steps - Multiple warning levels throughout the flow - Confirmation checkbox required before upload restore - Modal confirmation required before existing backup restore - Clear documentation of what gets restored and post-restore steps Dependencies: - Updated flask-swagger-ui from 4.11.1 to 5.21.0 Files modified: - app/templates/admin/restore.html (complete rewrite) - app/templates/admin/backups.html (added restore functionality) - app/routes/admin.py (enhanced restore route) - requirements.txt (updated flask-swagger-ui version) - RESTORE_BACKUP_IMPROVEMENTS.md (documentation) This provides a significantly improved user experience for the restore process while maintaining security and adding powerful new restore capabilities.
522 lines
17 KiB
Python
522 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,
|
|
'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):
|
|
"""Create a test project"""
|
|
project = Project(
|
|
name='Test Project',
|
|
description='A test project',
|
|
hourly_rate=75.0,
|
|
status='active'
|
|
)
|
|
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'
|
|
|
|
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"""
|
|
|
|
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'
|
|
|
|
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
|
|
|
|
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"""
|
|
|
|
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
|
|
|
|
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"""
|
|
|
|
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"""
|
|
|
|
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'
|
|
|