Implement dashboard functionality to manually trigger AI analysis for
feedback that was submitted before Phase 4 or failed analysis. Adds
detection mechanism to identify which feedback has been analyzed.
Features:
- Manual analysis trigger route: POST /feedback/{id}/analyze
- Detection of whether feedback has been analyzed (analysis.md exists)
- Dashboard UI button showing "Analyze" or "Re-analyze"
- Visual feedback for feedback without text content
- Comprehensive error handling and flash messages
Implementation:
- app/routes/dashboard.py: Added trigger_analysis() route handler
- app/routes/dashboard.py: Updated detail() to pass analysis status
- app/services/feedback_storage.py: Added has_analysis() helper method
- app/templates/dashboard/detail.html: Added analyze button UI
- tests/contract/test_dashboard_routes.py: Added 3 new contract tests
Testing:
- test_post_trigger_analysis_success: Successful manual analysis
- test_post_trigger_analysis_no_content: Reject empty content
- test_post_trigger_analysis_unauthenticated: Auth required
- All 49 tests passing (1 skipped)
User Experience:
- Green box with "Analyze" button for unanalyzed feedback
- Blue box with "Re-analyze" button for already analyzed feedback
- Red box with info message for feedback without text content
- Flash messages show success/error after analysis
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
437 lines
14 KiB
Python
437 lines
14 KiB
Python
"""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
|