Implement product selection landing page (Feature 002)
Adds landing page at root URL (/) that displays all active products with links to feedback submission forms. This replaces the requirement for users to know direct product URLs. Changes: - Added Product.load_active() method to filter and sort active products alphabetically - Created landing route blueprint with error handling and structured logging - Registered landing blueprint in app factory, replacing old index route - Created landing page template with product list and empty state - Added comprehensive contract tests (6 tests) covering active products, filtering, sorting, XSS prevention - Added integration test for complete user flow from landing page to submission form All 7 tests pass. User Story 1 (P1 - MVP) complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""Contract tests for landing page routes"""
|
||||
import pytest
|
||||
import os
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_products(app):
|
||||
"""Create test products with various configurations"""
|
||||
with app.app_context():
|
||||
products_dir = os.path.join(app.config['DATA_DIR'], 'products')
|
||||
|
||||
# Product 1: Active with description
|
||||
product1_dir = os.path.join(products_dir, 'product-001')
|
||||
os.makedirs(product1_dir, exist_ok=True)
|
||||
with open(os.path.join(product1_dir, 'config.yaml'), 'w') as f:
|
||||
yaml.dump({
|
||||
'product_id': 'product-001',
|
||||
'name': 'Zebra Product',
|
||||
'submission_url_slug': 'zebra-product',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': [],
|
||||
'status': 'active',
|
||||
'description': 'A product for testing'
|
||||
}, f)
|
||||
|
||||
# Product 2: Active without description
|
||||
product2_dir = os.path.join(products_dir, 'product-002')
|
||||
os.makedirs(product2_dir, exist_ok=True)
|
||||
with open(os.path.join(product2_dir, 'config.yaml'), 'w') as f:
|
||||
yaml.dump({
|
||||
'product_id': 'product-002',
|
||||
'name': 'Apple Product',
|
||||
'submission_url_slug': 'apple-product',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': [],
|
||||
'status': 'active'
|
||||
}, f)
|
||||
|
||||
# Product 3: Archived (should not appear)
|
||||
product3_dir = os.path.join(products_dir, 'product-003')
|
||||
os.makedirs(product3_dir, exist_ok=True)
|
||||
with open(os.path.join(product3_dir, 'config.yaml'), 'w') as f:
|
||||
yaml.dump({
|
||||
'product_id': 'product-003',
|
||||
'name': 'Archived Product',
|
||||
'submission_url_slug': 'archived-product',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': [],
|
||||
'status': 'archived',
|
||||
'description': 'This product is archived'
|
||||
}, f)
|
||||
|
||||
# Product 4: Active but missing slug (should not appear)
|
||||
product4_dir = os.path.join(products_dir, 'product-004')
|
||||
os.makedirs(product4_dir, exist_ok=True)
|
||||
with open(os.path.join(product4_dir, 'config.yaml'), 'w') as f:
|
||||
yaml.dump({
|
||||
'product_id': 'product-004',
|
||||
'name': 'No Slug Product',
|
||||
'submission_url_slug': '',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': [],
|
||||
'status': 'active',
|
||||
'description': 'Product with missing slug'
|
||||
}, f)
|
||||
|
||||
# Product 5: XSS test product
|
||||
product5_dir = os.path.join(products_dir, 'product-005')
|
||||
os.makedirs(product5_dir, exist_ok=True)
|
||||
with open(os.path.join(product5_dir, 'config.yaml'), 'w') as f:
|
||||
yaml.dump({
|
||||
'product_id': 'product-005',
|
||||
'name': '<script>alert("xss")</script>Evil Product',
|
||||
'submission_url_slug': 'xss-product',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': [],
|
||||
'status': 'active',
|
||||
'description': '<img src=x onerror=alert("xss")>Malicious description'
|
||||
}, f)
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_landing_page_with_products(client, test_products):
|
||||
"""T002: GET / with active products returns 200 with product list HTML"""
|
||||
response = client.get('/')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'<html' in response.data.lower()
|
||||
# Should show Apple Product (first alphabetically)
|
||||
assert b'Apple Product' in response.data
|
||||
# Should show Zebra Product
|
||||
assert b'Zebra Product' in response.data
|
||||
# Should NOT show archived product
|
||||
assert b'Archived Product' not in response.data
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_landing_page_no_products(client, app):
|
||||
"""T003: GET / with no active products returns 200 with empty state message"""
|
||||
# No test products created - products directory is empty
|
||||
response = client.get('/')
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b'No products are currently accepting feedback' in response.data
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_landing_page_filters_archived(client, test_products):
|
||||
"""T004: GET / excludes archived products"""
|
||||
response = client.get('/')
|
||||
|
||||
assert response.status_code == 200
|
||||
# Active products should be visible
|
||||
assert b'Apple Product' in response.data
|
||||
assert b'Zebra Product' in response.data
|
||||
# Archived product should NOT be visible
|
||||
assert b'Archived Product' not in response.data
|
||||
assert b'archived-product' not in response.data
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_landing_page_sorting(client, test_products):
|
||||
"""T005: GET / sorts products alphabetically (name, then product_id)"""
|
||||
response = client.get('/')
|
||||
|
||||
assert response.status_code == 200
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
# Apple Product should appear before Zebra Product (alphabetically)
|
||||
apple_pos = html.find('Apple Product')
|
||||
zebra_pos = html.find('Zebra Product')
|
||||
|
||||
assert apple_pos != -1, "Apple Product not found in response"
|
||||
assert zebra_pos != -1, "Zebra Product not found in response"
|
||||
assert apple_pos < zebra_pos, "Products not sorted alphabetically"
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_landing_page_xss_prevention(client, test_products):
|
||||
"""T006: GET / escapes HTML in product names (XSS prevention)"""
|
||||
response = client.get('/')
|
||||
|
||||
assert response.status_code == 200
|
||||
html = response.data.decode('utf-8')
|
||||
|
||||
# Script tags should be escaped, not executed
|
||||
assert '<script>' not in html, "Script tag not escaped in product name"
|
||||
assert 'alert("xss")' not in html or '<script>' in html, "XSS vulnerability in product name"
|
||||
|
||||
# Image onerror should be escaped
|
||||
assert '<img src=x onerror=' not in html, "XSS vulnerability in product description"
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
def test_get_landing_page_missing_slug(client, test_products):
|
||||
"""T007: GET / excludes products with missing submission_url_slug"""
|
||||
response = client.get('/')
|
||||
|
||||
assert response.status_code == 200
|
||||
# Product with missing slug should NOT appear
|
||||
assert b'No Slug Product' not in response.data
|
||||
# But other active products should appear
|
||||
assert b'Apple Product' in response.data
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Integration test for complete landing page flow"""
|
||||
import pytest
|
||||
import os
|
||||
import yaml
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_product_for_flow(app):
|
||||
"""Create a test product for the integration flow"""
|
||||
with app.app_context():
|
||||
products_dir = os.path.join(app.config['DATA_DIR'], 'products')
|
||||
product_dir = os.path.join(products_dir, 'flow-test-product')
|
||||
os.makedirs(product_dir, exist_ok=True)
|
||||
|
||||
with open(os.path.join(product_dir, 'config.yaml'), 'w') as f:
|
||||
yaml.dump({
|
||||
'product_id': 'flow-test-product',
|
||||
'name': 'Flow Test Product',
|
||||
'submission_url_slug': 'flow-test-product',
|
||||
'owner_language': 'en',
|
||||
'assigned_owner_ids': [],
|
||||
'status': 'active',
|
||||
'description': 'Product for integration flow testing'
|
||||
}, f)
|
||||
|
||||
yield 'flow-test-product'
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_landing_to_submission_flow(client, test_product_for_flow):
|
||||
"""T008: Complete flow - landing page → click product → submission form
|
||||
|
||||
Test the entire user journey:
|
||||
1. User visits landing page
|
||||
2. User sees products listed
|
||||
3. User clicks on a product link
|
||||
4. User is redirected to submission form for that product
|
||||
"""
|
||||
# Step 1: Visit landing page
|
||||
response = client.get('/')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Step 2: Verify product is listed
|
||||
assert b'Flow Test Product' in response.data
|
||||
assert b'flow-test-product' in response.data
|
||||
|
||||
# Step 3: Extract and verify product link
|
||||
html = response.data.decode('utf-8')
|
||||
assert '/submit/flow-test-product' in html, "Product link not found in landing page"
|
||||
|
||||
# Step 4: Click product link (navigate to submission form)
|
||||
submission_response = client.get('/submit/flow-test-product')
|
||||
|
||||
# Should reach submission form (not 404)
|
||||
assert submission_response.status_code == 200
|
||||
# Should be on submission form page (has form or product name)
|
||||
assert b'Flow Test Product' in submission_response.data or b'feedback' in submission_response.data.lower()
|
||||
Reference in New Issue
Block a user