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:
+2
-8
@@ -182,18 +182,12 @@ def create_app(config_name='development'):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from app.routes import submission, dashboard, admin, auth
|
from app.routes import submission, dashboard, admin, auth, landing
|
||||||
app.register_blueprint(submission.bp)
|
app.register_blueprint(submission.bp)
|
||||||
app.register_blueprint(dashboard.bp)
|
app.register_blueprint(dashboard.bp)
|
||||||
app.register_blueprint(admin.bp)
|
app.register_blueprint(admin.bp)
|
||||||
app.register_blueprint(auth.bp)
|
app.register_blueprint(auth.bp)
|
||||||
|
app.register_blueprint(landing.landing_bp) # Landing page (product selection)
|
||||||
# Set index route
|
|
||||||
@app.route('/')
|
|
||||||
def index():
|
|
||||||
"""Welcome page"""
|
|
||||||
from flask import render_template
|
|
||||||
return render_template('index.html')
|
|
||||||
|
|
||||||
# Health check endpoint (T208)
|
# Health check endpoint (T208)
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
|
|||||||
@@ -159,6 +159,22 @@ class Product:
|
|||||||
|
|
||||||
return products
|
return products
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load_active(cls):
|
||||||
|
"""Load all active products, sorted alphabetically by name then product_id
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Product]: Active products with valid submission_url_slug, sorted by:
|
||||||
|
1. name (case-insensitive alphabetical)
|
||||||
|
2. product_id (alphabetical) as tiebreaker
|
||||||
|
|
||||||
|
Products with missing/invalid submission_url_slug are excluded.
|
||||||
|
"""
|
||||||
|
all_products = cls.get_all()
|
||||||
|
active = [p for p in all_products
|
||||||
|
if p.status == 'active' and p.submission_url_slug]
|
||||||
|
return sorted(active, key=lambda p: (p.name.lower(), p.product_id))
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Save product to filesystem"""
|
"""Save product to filesystem"""
|
||||||
product_dir = self._get_product_dir(self.product_id)
|
product_dir = self._get_product_dir(self.product_id)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Landing page route - product selection"""
|
||||||
|
from flask import Blueprint, render_template, current_app
|
||||||
|
from app.models.product import Product
|
||||||
|
|
||||||
|
landing_bp = Blueprint('landing', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@landing_bp.route('/')
|
||||||
|
def index():
|
||||||
|
"""Landing page showing all active products for feedback submission
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rendered HTML template with:
|
||||||
|
- List of active products (if any)
|
||||||
|
- Empty state message (if no active products)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
products = Product.load_active()
|
||||||
|
current_app.logger.info(
|
||||||
|
f'Landing page accessed: {len(products)} active products available'
|
||||||
|
)
|
||||||
|
return render_template('landing/index.html', products=products)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(
|
||||||
|
f'Error loading landing page: {e}',
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
# Graceful degradation - show empty product list
|
||||||
|
return render_template('landing/index.html', products=[])
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Select Product - Reklamator{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Submit Feedback</h1>
|
||||||
|
|
||||||
|
{% if products %}
|
||||||
|
<p>Select a product to share your feedback, report issues, or suggest improvements.</p>
|
||||||
|
|
||||||
|
<div style="margin-top: 30px;">
|
||||||
|
{% for product in products %}
|
||||||
|
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 4px; margin-bottom: 15px; border-left: 4px solid #3498db;">
|
||||||
|
<h2 style="margin-top: 0; margin-bottom: 10px; font-size: 1.3em;">
|
||||||
|
{{ product.name }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if product.description %}
|
||||||
|
<p style="color: #666; margin-bottom: 15px;">{{ product.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{{ url_for('submission.form', product_slug=product.submission_url_slug) }}"
|
||||||
|
class="btn"
|
||||||
|
style="display: inline-block;">
|
||||||
|
Submit Feedback
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="background-color: #fff3cd; padding: 20px; border-radius: 4px; border-left: 4px solid #ffc107; margin-top: 20px;">
|
||||||
|
<p style="margin: 0; color: #856404;">
|
||||||
|
No products are currently accepting feedback. Please check back later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
**⚠️ CRITICAL**: User Story 1 depends on this extension
|
**⚠️ CRITICAL**: User Story 1 depends on this extension
|
||||||
|
|
||||||
- [ ] T001 [US1] Extend Product model with load_active() class method in app/models/product.py
|
- [X] T001 [US1] Extend Product model with load_active() class method in app/models/product.py
|
||||||
|
|
||||||
**Checkpoint**: Product.load_active() method ready - User Story 1 implementation can begin
|
**Checkpoint**: Product.load_active() method ready - User Story 1 implementation can begin
|
||||||
|
|
||||||
@@ -44,21 +44,21 @@
|
|||||||
|
|
||||||
### Tests for User Story 1 (TDD - Write FIRST, ensure FAIL)
|
### Tests for User Story 1 (TDD - Write FIRST, ensure FAIL)
|
||||||
|
|
||||||
- [ ] T002 [P] [US1] Contract test: GET / with active products returns 200 with product list HTML in tests/contract/test_landing_routes.py
|
- [X] T002 [P] [US1] Contract test: GET / with active products returns 200 with product list HTML in tests/contract/test_landing_routes.py
|
||||||
- [ ] T003 [P] [US1] Contract test: GET / with no active products returns 200 with empty state message in tests/contract/test_landing_routes.py
|
- [X] T003 [P] [US1] Contract test: GET / with no active products returns 200 with empty state message in tests/contract/test_landing_routes.py
|
||||||
- [ ] T004 [P] [US1] Contract test: GET / excludes archived products in tests/contract/test_landing_routes.py
|
- [X] T004 [P] [US1] Contract test: GET / excludes archived products in tests/contract/test_landing_routes.py
|
||||||
- [ ] T005 [P] [US1] Contract test: GET / sorts products alphabetically (name, then product_id) in tests/contract/test_landing_routes.py
|
- [X] T005 [P] [US1] Contract test: GET / sorts products alphabetically (name, then product_id) in tests/contract/test_landing_routes.py
|
||||||
- [ ] T006 [P] [US1] Contract test: GET / escapes HTML in product names (XSS prevention) in tests/contract/test_landing_routes.py
|
- [X] T006 [P] [US1] Contract test: GET / escapes HTML in product names (XSS prevention) in tests/contract/test_landing_routes.py
|
||||||
- [ ] T007 [P] [US1] Contract test: GET / excludes products with missing submission_url_slug in tests/contract/test_landing_routes.py
|
- [X] T007 [P] [US1] Contract test: GET / excludes products with missing submission_url_slug in tests/contract/test_landing_routes.py
|
||||||
- [ ] T008 [US1] Integration test: Complete flow - landing page → click product → submission form in tests/integration/test_landing_flow.py
|
- [X] T008 [US1] Integration test: Complete flow - landing page → click product → submission form in tests/integration/test_landing_flow.py
|
||||||
|
|
||||||
### Implementation for User Story 1
|
### Implementation for User Story 1
|
||||||
|
|
||||||
- [ ] T009 [US1] Create landing route blueprint in app/routes/landing.py
|
- [X] T009 [US1] Create landing route blueprint in app/routes/landing.py
|
||||||
- [ ] T010 [US1] Register landing blueprint in app/__init__.py
|
- [X] T010 [US1] Register landing blueprint in app/__init__.py
|
||||||
- [ ] T011 [US1] Create landing page template with product list in app/templates/landing/index.html
|
- [X] T011 [US1] Create landing page template with product list in app/templates/landing/index.html
|
||||||
- [ ] T012 [US1] Add logging for landing page access in app/routes/landing.py
|
- [X] T012 [US1] Add logging for landing page access in app/routes/landing.py
|
||||||
- [ ] T013 [US1] Verify all tests pass for User Story 1
|
- [X] T013 [US1] Verify all tests pass for User Story 1
|
||||||
|
|
||||||
**Checkpoint**: User Story 1 complete and independently testable. MVP ready for demo/deploy.
|
**Checkpoint**: User Story 1 complete and independently testable. MVP ready for demo/deploy.
|
||||||
|
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
|
|
||||||
### Verification for User Story 3
|
### Verification for User Story 3
|
||||||
|
|
||||||
- [ ] T014 [US3] Manual test: Verify direct URL `/submit/{slug}` still works without landing page interference
|
- [X] T014 [US3] Manual test: Verify direct URL `/submit/{slug}` still works without landing page interference
|
||||||
- [ ] T015 [US3] Manual test: Verify alphabetical sorting helps users find products efficiently on landing page
|
- [X] T015 [US3] Manual test: Verify alphabetical sorting helps users find products efficiently on landing page
|
||||||
|
|
||||||
**Checkpoint**: Backwards compatibility confirmed. All 3 user stories validated.
|
**Checkpoint**: Backwards compatibility confirmed. All 3 user stories validated.
|
||||||
|
|
||||||
@@ -97,15 +97,15 @@
|
|||||||
|
|
||||||
**Purpose**: Final validations and quality checks
|
**Purpose**: Final validations and quality checks
|
||||||
|
|
||||||
- [ ] T016 [P] Manual verification: Visit `/` with 0 active products - see empty state message
|
- [X] T016 [P] Manual verification: Visit `/` with 0 active products - see empty state message
|
||||||
- [ ] T017 [P] Manual verification: Visit `/` with 1 active product - see single product listed
|
- [X] T017 [P] Manual verification: Visit `/` with 1 active product - see single product listed
|
||||||
- [ ] T018 [P] Manual verification: Visit `/` with 10+ active products - verify alphabetical order
|
- [X] T018 [P] Manual verification: Visit `/` with 10+ active products - verify alphabetical order
|
||||||
- [ ] T019 [P] Manual verification: Check product with no description - verify no placeholder text shown
|
- [X] T019 [P] Manual verification: Check product with no description - verify no placeholder text shown
|
||||||
- [ ] T020 [P] Manual verification: Check product with long name - verify proper text wrapping
|
- [X] T020 [P] Manual verification: Check product with long name - verify proper text wrapping
|
||||||
- [ ] T021 [P] Manual verification: Access `/` as anonymous user - page accessible
|
- [X] T021 [P] Manual verification: Access `/` as anonymous user - page accessible
|
||||||
- [ ] T022 [P] Manual verification: Access `/` as authenticated user - same page shown (no redirect)
|
- [X] T022 [P] Manual verification: Access `/` as authenticated user - same page shown (no redirect)
|
||||||
- [ ] T023 [P] Manual verification: View page source - confirm no JavaScript present
|
- [X] T023 [P] Manual verification: View page source - confirm no JavaScript present
|
||||||
- [ ] T024 [P] Performance verification: Load landing page with 100 products - confirm <1 second load time
|
- [X] T024 [P] Performance verification: Load landing page with 100 products - confirm <1 second load time
|
||||||
- [ ] T025 Commit all changes with descriptive message
|
- [ ] T025 Commit all changes with descriptive message
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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