Implement Phase 5: Product Owner Dashboard (User Story 3)

Add complete dashboard functionality for product owners and administrators to view, filter, search, and manage feedback submissions following Test-First Discipline.

Tests (T093-T105):
- Add 12 contract tests for dashboard routes (authentication, listing, filtering, search, detail view, status updates, attachment downloads, access control)
- Add 2 integration tests for complete dashboard workflow and access control enforcement
- All tests written first and verified to fail before implementation

Services (T111-T118):
- Enhance FeedbackStorageService with load_feedback_list() for pagination, filtering, searching, and sorting
- Add load_feedback_detail() to load complete feedback with attachments and analysis
- Add update_feedback_status_by_id() for status management
- Add get_attachment_path() with path traversal prevention

Routes (T119-T134):
- Implement GET /dashboard with filters, search, and pagination (50 items/page)
- Implement GET /feedback/<id> detail view with role-based access control
- Implement POST /feedback/<id>/status for status updates
- Implement GET /feedback/<id>/attachment/<filename> for secure file downloads
- Add access control helpers (administrators see all products, owners see only assigned)

Templates (T135-T136):
- Create dashboard/list.html with filter form, search, and pagination
- Create dashboard/detail.html with status update form and attachment links
- Create error_403.html for access denied
- Create error_404.html for not found

Integration & Bug Fixes:
- Update auth routes to remove /auth prefix and redirect to dashboard after login
- Update Feedback.VALID_STATUSES to include dashboard statuses (in_progress, resolved, closed)
- Register error handlers for 403 and 404 in app factory
- Fix test fixtures to use correct users.yaml format and User.hash_password()

Test Results: 39 passed, 1 skipped (all Phase 5 tests passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-16 20:11:16 +02:00
co-authored by Claude
parent 0b15d8e3bc
commit adbfd23c26
11 changed files with 1403 additions and 13 deletions
+324
View File
@@ -0,0 +1,324 @@
"""Contract tests for dashboard routes"""
import pytest
import os
import yaml
import io
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