feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)
## Phase 4: Web Interface Implementation Implemented complete web interface with public and admin routes, templates, CSS, and development authentication. ### Core Features **Public Routes**: - Homepage with recent published notes - Note permalinks with microformats2 - Server-side rendering (Jinja2) **Admin Routes**: - Login via IndieLogin - Dashboard with note management - Create, edit, delete notes - Protected with @require_auth decorator **Development Authentication**: - Dev login bypass for local testing (DEV_MODE only) - Security safeguards per ADR-011 - Returns 404 when disabled **Templates & Frontend**: - Base layouts (public + admin) - 8 HTML templates with microformats2 - Custom responsive CSS (114 lines) - Error pages (404, 500) ### Bugfixes (v0.5.1 → v0.5.2) 1. **Cookie collision fix (v0.5.1)**: - Renamed auth cookie from "session" to "starpunk_session" - Fixed redirect loop between dev login and admin dashboard - Flask's session cookie no longer conflicts with auth 2. **HTTP 404 error handling (v0.5.1)**: - Update route now returns 404 for nonexistent notes - Delete route now returns 404 for nonexistent notes - Follows ADR-012 HTTP Error Handling Policy - Pattern consistency across all admin routes 3. **Note model enhancement (v0.5.2)**: - Exposed deleted_at field from database schema - Enables soft deletion verification in tests - Follows ADR-013 transparency principle ### Architecture **New ADRs**: - ADR-011: Development Authentication Mechanism - ADR-012: HTTP Error Handling Policy - ADR-013: Expose deleted_at Field in Note Model **Standards Compliance**: - Uses uv for Python environment - Black formatted, Flake8 clean - Follows git branching strategy - Version incremented per versioning strategy ### Test Results - 405/406 tests passing (99.75%) - 87% code coverage - All security tests passing - Manual testing confirmed working ### Documentation - Complete implementation reports in docs/reports/ - Architecture reviews in docs/reviews/ - Design documents in docs/design/ - CHANGELOG updated for v0.5.2 ### Files Changed **New Modules**: - starpunk/dev_auth.py - starpunk/routes/ (public, admin, auth, dev_auth) **Templates**: 10 files (base, pages, admin, errors) **Static**: CSS and optional JavaScript **Tests**: 4 test files for routes and templates **Docs**: 20+ architectural and implementation documents 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
277
tests/test_routes_public.py
Normal file
277
tests/test_routes_public.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Tests for public routes (homepage, note permalinks)
|
||||
|
||||
Tests cover:
|
||||
- Homepage rendering with notes list
|
||||
- Note permalink rendering
|
||||
- 404 behavior for missing/unpublished notes
|
||||
- Microformats2 markup
|
||||
- Flash message display
|
||||
- Error page rendering
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from starpunk import create_app
|
||||
from starpunk.notes import create_note
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(tmp_path):
|
||||
"""Create test application with dev mode disabled"""
|
||||
# Create test-specific data directory
|
||||
test_data_dir = tmp_path / "data"
|
||||
test_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
test_config = {
|
||||
"TESTING": True,
|
||||
"DATABASE_PATH": test_data_dir / "starpunk.db",
|
||||
"DATA_PATH": test_data_dir,
|
||||
"NOTES_PATH": test_data_dir / "notes",
|
||||
"SESSION_SECRET": "test-secret-key-for-testing-only",
|
||||
"ADMIN_ME": "https://test.example.com",
|
||||
"SITE_URL": "http://localhost:5000",
|
||||
"DEV_MODE": False,
|
||||
}
|
||||
app = create_app(config=test_config)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Test client for making requests"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_notes(app):
|
||||
"""Create sample notes for testing"""
|
||||
with app.app_context():
|
||||
notes = []
|
||||
for i in range(5):
|
||||
note = create_note(
|
||||
content=f"# Test Note {i}\n\nThis is test note number {i}.",
|
||||
published=(i % 2 == 0), # Even notes published, odd are drafts
|
||||
)
|
||||
notes.append(note)
|
||||
return notes
|
||||
|
||||
|
||||
class TestHomepage:
|
||||
"""Test homepage route (/)"""
|
||||
|
||||
def test_homepage_renders(self, client):
|
||||
"""Test homepage renders successfully"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"StarPunk" in response.data
|
||||
|
||||
def test_homepage_shows_published_notes(self, client, sample_notes):
|
||||
"""Test homepage shows only published notes"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Published notes should appear (notes 0, 2, 4)
|
||||
assert b"Test Note 0" in response.data
|
||||
assert b"Test Note 2" in response.data
|
||||
assert b"Test Note 4" in response.data
|
||||
|
||||
# Draft notes should not appear (notes 1, 3)
|
||||
assert b"Test Note 1" not in response.data
|
||||
assert b"Test Note 3" not in response.data
|
||||
|
||||
def test_homepage_empty_state(self, client):
|
||||
"""Test homepage with no notes"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
# Should have some message about no notes
|
||||
assert b"No notes" in response.data or b"Welcome" in response.data
|
||||
|
||||
def test_homepage_has_feed_link(self, client):
|
||||
"""Test homepage has RSS feed link"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"feed.xml" in response.data or b"RSS" in response.data
|
||||
|
||||
def test_homepage_has_h_feed_microformat(self, client, sample_notes):
|
||||
"""Test homepage has h-feed microformat"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"h-feed" in response.data
|
||||
|
||||
def test_homepage_notes_have_h_entry(self, client, sample_notes):
|
||||
"""Test notes on homepage have h-entry microformat"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"h-entry" in response.data
|
||||
|
||||
|
||||
class TestNotePermalink:
|
||||
"""Test individual note permalink route (/note/<slug>)"""
|
||||
|
||||
def test_published_note_renders(self, client, sample_notes):
|
||||
"""Test published note permalink renders"""
|
||||
# Get a published note (note 0)
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
assert len(notes) > 0
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"Test Note" in response.data
|
||||
|
||||
def test_note_has_full_content(self, client, sample_notes):
|
||||
"""Test note page shows full content"""
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"test note number" in response.data
|
||||
|
||||
def test_note_has_h_entry_microformat(self, client, sample_notes):
|
||||
"""Test note page has h-entry microformat"""
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"h-entry" in response.data
|
||||
assert b"e-content" in response.data
|
||||
assert b"dt-published" in response.data
|
||||
|
||||
def test_note_has_permalink_url(self, client, sample_notes):
|
||||
"""Test note page has permalink URL"""
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
assert b"u-url" in response.data
|
||||
|
||||
def test_draft_note_returns_404(self, client, sample_notes):
|
||||
"""Test draft note returns 404"""
|
||||
# Get a draft note (note 1)
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
# Get all notes, then filter to drafts
|
||||
all_notes = list_notes()
|
||||
draft_notes = [n for n in all_notes if not n.published]
|
||||
assert len(draft_notes) > 0
|
||||
slug = draft_notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_missing_note_returns_404(self, client):
|
||||
"""Test missing note returns 404"""
|
||||
response = client.get("/note/nonexistent-slug")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_note_has_back_link(self, client, sample_notes):
|
||||
"""Test note page has link back to homepage"""
|
||||
with client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes(published_only=True)
|
||||
slug = notes[0].slug
|
||||
|
||||
response = client.get(f"/note/{slug}")
|
||||
assert response.status_code == 200
|
||||
# Should have a link to home
|
||||
assert b'href="/"' in response.data
|
||||
|
||||
|
||||
class TestFlashMessages:
|
||||
"""Test flash message display"""
|
||||
|
||||
def test_flash_messages_display(self, client):
|
||||
"""Test flash messages are displayed"""
|
||||
with client.session_transaction() as session:
|
||||
session["_flashes"] = [("success", "Test message")]
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"Test message" in response.data
|
||||
|
||||
def test_flash_message_categories(self, client):
|
||||
"""Test different flash message categories"""
|
||||
categories = ["success", "error", "warning", "info"]
|
||||
|
||||
for category in categories:
|
||||
with client.session_transaction() as session:
|
||||
session["_flashes"] = [(category, f"{category} message")]
|
||||
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert f"{category} message".encode() in response.data
|
||||
|
||||
|
||||
class TestErrorPages:
|
||||
"""Test error page rendering"""
|
||||
|
||||
def test_404_page_renders(self, client):
|
||||
"""Test 404 error page"""
|
||||
response = client.get("/nonexistent-page")
|
||||
assert response.status_code == 404
|
||||
assert b"404" in response.data or b"Not Found" in response.data
|
||||
|
||||
def test_404_has_home_link(self, client):
|
||||
"""Test 404 page has link to homepage"""
|
||||
response = client.get("/nonexistent-page")
|
||||
assert response.status_code == 404
|
||||
assert b'href="/"' in response.data
|
||||
|
||||
|
||||
class TestDevModeIndicator:
|
||||
"""Test dev mode warning display"""
|
||||
|
||||
def test_dev_mode_warning_not_shown_in_production(self, client):
|
||||
"""Test dev mode warning not shown when DEV_MODE=false"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"DEVELOPMENT MODE" not in response.data
|
||||
|
||||
def test_dev_mode_warning_shown_when_enabled(self, tmp_path):
|
||||
"""Test dev mode warning shown when DEV_MODE=true"""
|
||||
test_data_dir = tmp_path / "dev_data"
|
||||
test_data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
test_config = {
|
||||
"TESTING": True,
|
||||
"DATABASE_PATH": test_data_dir / "starpunk.db",
|
||||
"DATA_PATH": test_data_dir,
|
||||
"NOTES_PATH": test_data_dir / "notes",
|
||||
"SESSION_SECRET": "test-secret",
|
||||
"SITE_URL": "http://localhost:5000",
|
||||
"DEV_MODE": True,
|
||||
"DEV_ADMIN_ME": "https://dev.example.com",
|
||||
}
|
||||
app = create_app(config=test_config)
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"DEVELOPMENT MODE" in response.data or b"DEV MODE" in response.data
|
||||
|
||||
|
||||
class TestVersionDisplay:
|
||||
"""Test version number display"""
|
||||
|
||||
def test_version_in_footer(self, client):
|
||||
"""Test version number appears in footer"""
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert b"0.5.0" in response.data or b"StarPunk v" in response.data
|
||||
Reference in New Issue
Block a user