Files

437 lines
14 KiB
Python
Raw Permalink Normal View History

"""Contract tests for dashboard routes"""
import pytest
import os
import yaml
import io
from unittest.mock import Mock, patch
from app.models.user import User
@pytest.fixture
def test_product(app):
"""Create a test product with feedback"""
with app.app_context():
# Create test product directory and config
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'test-product')
os.makedirs(product_dir, exist_ok=True)
# Create product config
config_file = os.path.join(product_dir, 'config.yaml')
config_data = {
'product_id': 'test-product',
'name': 'Test Product',
'submission_url_slug': 'test-product',
'owner_language': 'en',
'assigned_owner_ids': ['usr_owner1'],
'status': 'active'
}
with open(config_file, 'w') as f:
yaml.dump(config_data, f)
# Create feedback directory
feedback_dir = os.path.join(product_dir, 'feedback')
os.makedirs(feedback_dir, exist_ok=True)
# Create test feedback
feedback_id = 'test-feedback-001'
feedback_path = os.path.join(feedback_dir, feedback_id)
os.makedirs(feedback_path, exist_ok=True)
# Create feedback metadata
metadata = {
'feedback_id': feedback_id,
'product_id': 'test-product',
'status': 'new',
'submitted_at': '2025-10-16T10:00:00Z',
'has_attachments': True,
'attachment_count': 1,
'category': 'bug',
'original_language': 'en'
}
with open(os.path.join(feedback_path, 'metadata.yaml'), 'w') as f:
yaml.dump(metadata, f)
# Create feedback content
with open(os.path.join(feedback_path, 'content.txt'), 'w') as f:
f.write('Test feedback content')
# Create attachments directory and file
attachments_dir = os.path.join(feedback_path, 'attachments')
os.makedirs(attachments_dir, exist_ok=True)
with open(os.path.join(attachments_dir, 'test.txt'), 'w') as f:
f.write('test attachment content')
yield {
'product_id': 'test-product',
'feedback_id': feedback_id
}
@pytest.fixture
def test_users(app):
"""Create test users (admin and product owner)"""
users_file = os.path.join(app.config['DATA_DIR'], 'users.yaml')
# User model expects format: {'users': {user_id: user_data}}
users_data = {
'users': {
'usr_admin': {
'user_id': 'usr_admin',
'username': 'admin',
'email': 'admin@example.com',
'password_hash': User.hash_password('admin123'),
'role': 'administrator',
'product_ids': [],
'is_active': True
},
'usr_owner1': {
'user_id': 'usr_owner1',
'username': 'owner1',
'email': 'owner1@example.com',
'password_hash': User.hash_password('owner123'),
'role': 'product_owner',
'product_ids': ['test-product'],
'is_active': True
},
'usr_owner2': {
'user_id': 'usr_owner2',
'username': 'owner2',
'email': 'owner2@example.com',
'password_hash': User.hash_password('owner456'),
'role': 'product_owner',
'product_ids': ['other-product'],
'is_active': True
}
}
}
with open(users_file, 'w') as f:
yaml.dump(users_data, f)
yield users_data
@pytest.mark.contract
def test_get_login(client):
"""T093: Contract test for GET /login
Expected: 200 OK with HTML login form
"""
response = client.get('/login')
assert response.status_code == 200
assert b'<form' in response.data
assert b'username' in response.data.lower() or b'email' in response.data.lower()
assert b'password' in response.data.lower()
@pytest.mark.contract
def test_post_login_valid_credentials(client, app, test_users):
"""T094: Contract test for POST /login with valid credentials
Expected: 302 redirect to dashboard with session established
"""
data = {
'username': 'admin',
'password': 'admin123'
}
response = client.post('/login', data=data, follow_redirects=False)
# Should redirect (302) to dashboard or home
assert response.status_code == 302
# Follow redirect and verify user is logged in
response_redirected = client.get(response.location, follow_redirects=True)
assert response_redirected.status_code == 200
@pytest.mark.contract
def test_post_login_invalid_credentials(client, app, test_users):
"""T095: Contract test for POST /login with invalid credentials (401)
Expected: 401 Unauthorized or redirect back to login with error message
"""
data = {
'username': 'admin',
'password': 'wrongpassword'
}
response = client.post('/login', data=data)
# Should return error (401 or 200 with error message)
assert response.status_code in [200, 401]
if response.status_code == 200:
# If returns 200, should show error message
assert b'invalid' in response.data.lower() or b'incorrect' in response.data.lower() or b'error' in response.data.lower()
@pytest.mark.contract
def test_get_logout(client, app, test_users):
"""T096: Contract test for GET /logout
Expected: 302 redirect to login or home, session cleared
"""
# First login
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
# Then logout
response = client.get('/logout', follow_redirects=False)
assert response.status_code == 302
@pytest.mark.contract
def test_get_dashboard_authenticated(client, app, test_users, test_product):
"""T097: Contract test for GET /dashboard (authenticated)
Expected: 200 OK with dashboard showing feedback list
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
response = client.get('/dashboard')
assert response.status_code == 200
assert b'feedback' in response.data.lower() or b'dashboard' in response.data.lower()
@pytest.mark.contract
def test_get_dashboard_unauthenticated(client):
"""T098: Contract test for GET /dashboard (unauthenticated redirect)
Expected: 302 redirect to login page
"""
response = client.get('/dashboard', follow_redirects=False)
# Should redirect to login
assert response.status_code == 302
assert '/login' in response.location
@pytest.mark.contract
def test_get_dashboard_with_filters(client, app, test_users, test_product):
"""T099: Contract test for GET /dashboard with filters
Expected: 200 OK with filtered feedback list
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
# Request with filters
response = client.get('/dashboard?category=bug&status=new')
assert response.status_code == 200
@pytest.mark.contract
def test_get_dashboard_with_search(client, app, test_users, test_product):
"""T100: Contract test for GET /dashboard with search query
Expected: 200 OK with search results
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
# Request with search query
response = client.get('/dashboard?search=test')
assert response.status_code == 200
@pytest.mark.contract
def test_get_feedback_detail(client, app, test_users, test_product):
"""T101: Contract test for GET /feedback/{id} detail view
Expected: 200 OK with feedback detail page showing content, metadata, attachments
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
feedback_id = test_product['feedback_id']
response = client.get(f'/feedback/{feedback_id}')
assert response.status_code == 200
assert b'Test feedback content' in response.data or b'feedback' in response.data.lower()
@pytest.mark.contract
def test_post_feedback_status_update(client, app, test_users, test_product):
"""T102: Contract test for POST /feedback/{id}/status update
Expected: 200/302 success, metadata.yaml updated with new status
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
feedback_id = test_product['feedback_id']
data = {
'status': 'in_progress'
}
response = client.post(f'/feedback/{feedback_id}/status', data=data)
# Should succeed
assert response.status_code in [200, 302]
@pytest.mark.contract
def test_get_attachment_download(client, app, test_users, test_product):
"""T103: Contract test for GET /feedback/{id}/attachment/{filename} download
Expected: 200 OK with file content, correct Content-Disposition header
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
feedback_id = test_product['feedback_id']
response = client.get(f'/feedback/{feedback_id}/attachment/test.txt')
assert response.status_code == 200
assert b'test attachment content' in response.data
# Should have download headers
assert 'Content-Disposition' in response.headers or 'content-disposition' in response.headers
@pytest.mark.contract
def test_access_control_owner_products(client, app, test_users, test_product):
"""T104: Contract test for access control (owner sees only assigned products)
Expected: Product owner can only access feedback for their assigned products
"""
# Login as owner1 (has access to test-product)
client.post('/login', data={'username': 'owner1', 'password': 'owner123'})
feedback_id = test_product['feedback_id']
# Should have access to feedback from test-product
response = client.get(f'/feedback/{feedback_id}')
assert response.status_code == 200
# Logout
client.get('/logout')
# Login as owner2 (only has access to other-product)
client.post('/login', data={'username': 'owner2', 'password': 'owner456'})
# Should NOT have access to feedback from test-product
response = client.get(f'/feedback/{feedback_id}')
assert response.status_code == 403 # Forbidden
@pytest.mark.contract
def test_post_trigger_analysis_success(client, app, test_users, test_product):
"""Contract test for POST /feedback/{id}/analyze - manual analysis trigger
Expected: 302 redirect with success message, status updated to 'analyzed'
"""
# Login first
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
feedback_id = test_product['feedback_id']
# Mock the Claude API response
mock_api_response = Mock()
mock_api_response.content = [Mock(text="""
# Feedback Analysis
**Category**: bug
**Original Language**: en
**Summary**: User reports a test feedback issue
**Translation**: (same as original)
""")]
# Patch AI analyzer
with patch('app.routes.dashboard.ClaudeAnalyzer') as mock_analyzer_class:
mock_analyzer = Mock()
mock_analyzer.analyze_feedback.return_value = Mock(
category='bug',
original_language='en',
summary='User reports a test feedback issue',
translation='(same as original)',
raw_analysis=mock_api_response.content[0].text
)
mock_analyzer_class.return_value = mock_analyzer
with patch('app.routes.dashboard.os.getenv', return_value='test-api-key'):
response = client.post(f'/feedback/{feedback_id}/analyze', follow_redirects=False)
# Should redirect
assert response.status_code == 302
assert f'/feedback/{feedback_id}' in response.location
# Verify analysis was saved
with app.app_context():
data_dir = app.config['DATA_DIR']
analysis_file = os.path.join(
data_dir, 'products', 'test-product', 'feedback', feedback_id, 'analysis.md'
)
assert os.path.exists(analysis_file)
@pytest.mark.contract
def test_post_trigger_analysis_no_content(client, app, test_users, test_product):
"""Contract test for POST /feedback/{id}/analyze - feedback with empty text content
Expected: 302 redirect with error message
"""
# Create feedback with empty content.txt
with app.app_context():
product_dir = os.path.join(app.config['DATA_DIR'], 'products', 'test-product')
feedback_id = 'test-feedback-empty-text'
feedback_path = os.path.join(product_dir, 'feedback', feedback_id)
os.makedirs(feedback_path, exist_ok=True)
metadata = {
'feedback_id': feedback_id,
'product_id': 'test-product',
'status': 'new',
'submitted_at': '2025-10-16T10:00:00Z',
'has_attachments': True,
'attachment_count': 1
}
with open(os.path.join(feedback_path, 'metadata.yaml'), 'w') as f:
yaml.dump(metadata, f)
# Create empty content.txt
with open(os.path.join(feedback_path, 'content.txt'), 'w') as f:
f.write('')
# Create attachments directory to show this has attachments only
attachments_dir = os.path.join(feedback_path, 'attachments')
os.makedirs(attachments_dir, exist_ok=True)
with open(os.path.join(attachments_dir, 'image.png'), 'w') as f:
f.write('fake image data')
# Login and try to analyze
client.post('/login', data={'username': 'admin', 'password': 'admin123'})
response = client.post(f'/feedback/{feedback_id}/analyze', follow_redirects=True)
# Should show error message
assert response.status_code == 200
assert b'Cannot analyze' in response.data or b'no text content' in response.data
@pytest.mark.contract
def test_post_trigger_analysis_unauthenticated(client, app, test_product):
"""Contract test for POST /feedback/{id}/analyze - unauthenticated access
Expected: 302 redirect to login
"""
feedback_id = test_product['feedback_id']
response = client.post(f'/feedback/{feedback_id}/analyze', follow_redirects=False)
# Should redirect to login
assert response.status_code == 302
assert '/login' in response.location