From 155c8f75695225f6a48e856f0047883fbfb2c2fe Mon Sep 17 00:00:00 2001 From: codex_a Date: Sun, 1 Mar 2026 12:17:55 +0000 Subject: [PATCH] fix: nav/footer wireframe, honeypot CSP, explore topics, comment E2E coverage - Replace nav inline newsletter form with Subscribe CTA link per wireframe - Remove newsletter form from footer; add Connect section with social/RSS links - Fix honeypot inputs using hidden attribute (inline style blocked by CSP) - Add available_tags to HomePage.get_context for Explore Topics section - Add data-comment-form attribute to main comment form for reliable locating - Seed approved comment in E2E content for reply flow testing - Expand test_comments.py: moderation message, not-immediately-visible, missing fields, reply form visible, reply submission - Make COMMENT_RATE_LIMIT_PER_MINUTE configurable; set 100 in dev to prevent E2E test exhaustion; update rate limit unit test with override_settings - Update newsletter/home E2E tests to reflect nav form removal - Update unit test to assert no nav/footer newsletter forms Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/blog/models.py | 5 ++ apps/blog/tests/test_views.py | 8 +- apps/comments/tests/test_more.py | 2 + apps/comments/views.py | 3 +- .../management/commands/seed_e2e_content.py | 13 ++- config/settings/development.py | 2 + e2e/test_comments.py | 85 ++++++++++++++----- e2e/test_home.py | 7 +- e2e/test_newsletter.py | 5 +- templates/blog/article_page.html | 7 +- templates/components/footer.html | 18 +++- templates/components/nav.html | 12 +-- templates/components/newsletter_form.html | 2 +- theme/static/css/styles.css | 2 +- 14 files changed, 123 insertions(+), 48 deletions(-) diff --git a/apps/blog/models.py b/apps/blog/models.py index 0eddc3d..7326b2b 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -42,6 +42,11 @@ class HomePage(Page): ctx["featured_article"] = self.featured_article ctx["latest_articles"] = articles ctx["more_articles"] = articles[:3] + ctx["available_tags"] = ( + Tag.objects.filter( + id__in=ArticlePage.objects.live().public().values_list("tags__id", flat=True) + ).distinct().order_by("name") + ) return ctx diff --git a/apps/blog/tests/test_views.py b/apps/blog/tests/test_views.py index d30a212..e8e9982 100644 --- a/apps/blog/tests/test_views.py +++ b/apps/blog/tests/test_views.py @@ -69,8 +69,12 @@ def test_newsletter_forms_render_in_nav_and_footer(client, home_page): resp = client.get("/") html = resp.content.decode() assert resp.status_code == 200 - assert 'name="source" value="nav"' in html - assert 'name="source" value="footer"' in html + # Nav has a Subscribe CTA link (no inline form — wireframe spec) + assert 'href="#newsletter"' in html + # Footer has Connect section with social/RSS links (no newsletter form) + assert "Connect" in html + assert 'name="source" value="nav"' not in html + assert 'name="source" value="footer"' not in html @pytest.mark.django_db diff --git a/apps/comments/tests/test_more.py b/apps/comments/tests/test_more.py index 94fd369..2deb667 100644 --- a/apps/comments/tests/test_more.py +++ b/apps/comments/tests/test_more.py @@ -1,5 +1,6 @@ import pytest from django.core.cache import cache +from django.test import override_settings from apps.comments.forms import CommentForm @@ -11,6 +12,7 @@ def test_comment_form_rejects_blank_body(): @pytest.mark.django_db +@override_settings(COMMENT_RATE_LIMIT_PER_MINUTE=3) def test_comment_rate_limit(client, article_page): cache.clear() payload = { diff --git a/apps/comments/views.py b/apps/comments/views.py index 5e908a2..6c34fc3 100644 --- a/apps/comments/views.py +++ b/apps/comments/views.py @@ -33,7 +33,8 @@ class CommentCreateView(View): ip = client_ip_from_request(request) key = f"comment-rate:{ip}" count = cache.get(key, 0) - if count >= 3: + rate_limit = getattr(settings, "COMMENT_RATE_LIMIT_PER_MINUTE", 3) + if count >= rate_limit: return HttpResponse(status=429) cache.set(key, count + 1, timeout=60) diff --git a/apps/core/management/commands/seed_e2e_content.py b/apps/core/management/commands/seed_e2e_content.py index 5ee6dc6..fe61f2a 100644 --- a/apps/core/management/commands/seed_e2e_content.py +++ b/apps/core/management/commands/seed_e2e_content.py @@ -6,6 +6,7 @@ from wagtail.models import Page, Site from apps.authors.models import Author from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata +from apps.comments.models import Comment from apps.legal.models import LegalIndexPage, LegalPage @@ -51,7 +52,17 @@ class Command(BaseCommand): article_index.add_child(instance=article) article.save_revision().publish() - # Tagged article — used by tag-filter E2E tests + # Seed one approved top-level comment on the primary article for reply E2E tests + if not Comment.objects.filter(article=article, author_name="E2E Approved Commenter").exists(): + Comment.objects.create( + article=article, + author_name="E2E Approved Commenter", + author_email="approved@example.com", + body="This is a seeded approved comment for reply testing.", + is_approved=True, + ) + + tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools") TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"}) tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first() diff --git a/config/settings/development.py b/config/settings/development.py index 1468b9f..b9c3ff8 100644 --- a/config/settings/development.py +++ b/config/settings/development.py @@ -26,3 +26,5 @@ try: MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE] except Exception: pass + +COMMENT_RATE_LIMIT_PER_MINUTE = 100 diff --git a/e2e/test_comments.py b/e2e/test_comments.py index c104588..26a6073 100644 --- a/e2e/test_comments.py +++ b/e2e/test_comments.py @@ -12,39 +12,87 @@ def _go_to_article(page: Page, base_url: str) -> None: page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle") -@pytest.mark.e2e -def test_valid_comment_submission_redirects(page: Page, base_url: str) -> None: - _go_to_article(page, base_url) - - # Fill the main comment form (not a reply form) - form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment")) - form.locator('input[name="author_name"]').fill("E2E Tester") - form.locator('input[name="author_email"]').fill("e2e@example.com") - form.locator('textarea[name="body"]').fill("This is a test comment from Playwright.") +def _submit_comment(page: Page, *, name: str = "E2E Tester", email: str = "e2e@example.com", body: str) -> None: + """Fill and submit the main (non-reply) comment form.""" + form = page.locator("form[data-comment-form]") + form.locator('input[name="author_name"]').fill(name) + form.locator('input[name="author_email"]').fill(email) + form.locator('textarea[name="body"]').fill(body) form.get_by_role("button", name="Post comment").click() - # Successful submission redirects back to the article with ?commented=1 + +@pytest.mark.e2e +def test_valid_comment_shows_moderation_message(page: Page, base_url: str) -> None: + """Successful comment submission must show the awaiting-moderation banner.""" + _go_to_article(page, base_url) + _submit_comment(page, body="This is a test comment from Playwright.") + page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000) - assert "commented=1" in page.url + expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible() + + +@pytest.mark.e2e +def test_valid_comment_not_immediately_visible(page: Page, base_url: str) -> None: + """Submitted comment must NOT appear in the comments list before moderation.""" + _go_to_article(page, base_url) + unique_body = "Unique unmoderated comment body xq7z" + _submit_comment(page, body=unique_body) + + page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000) + expect(page.get_by_text(unique_body)).not_to_be_visible() @pytest.mark.e2e def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None: _go_to_article(page, base_url) + _submit_comment(page, body=" ") # whitespace-only body + page.wait_for_load_state("networkidle") - form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment")) - form.locator('input[name="author_name"]').fill("E2E Tester") + expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible() + assert "commented=1" not in page.url + + +@pytest.mark.e2e +def test_missing_name_shows_form_errors(page: Page, base_url: str) -> None: + _go_to_article(page, base_url) + + form = page.locator("form[data-comment-form]") + form.locator('input[name="author_name"]').fill("") form.locator('input[name="author_email"]').fill("e2e@example.com") - form.locator('textarea[name="body"]').fill(" ") # whitespace-only body + form.locator('textarea[name="body"]').fill("Comment without a name.") form.get_by_role("button", name="Post comment").click() page.wait_for_load_state("networkidle") - # The page re-renders with the error summary visible - expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible() - # URL must NOT have ?commented=1 — form was not accepted assert "commented=1" not in page.url +@pytest.mark.e2e +def test_reply_form_visible_on_approved_comment(page: Page, base_url: str) -> None: + """An approved seeded comment must display a reply form.""" + _go_to_article(page, base_url) + + # The seeded approved comment should be visible + expect(page.get_by_text("E2E Approved Commenter")).to_be_visible() + # And a Reply button for it + expect(page.get_by_role("button", name="Reply")).to_be_visible() + + +@pytest.mark.e2e +def test_reply_submission_redirects(page: Page, base_url: str) -> None: + """Submitting a reply to an approved comment should redirect with commented=1.""" + _go_to_article(page, base_url) + + # The reply form is always visible below the approved seeded comment + reply_form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Reply")).first + reply_form.locator('input[name="author_name"]').fill("E2E Replier") + reply_form.locator('input[name="author_email"]').fill("replier@example.com") + reply_form.locator('textarea[name="body"]').fill("This is a test reply.") + reply_form.get_by_role("button", name="Reply").click() + + page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000) + expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible() + + @pytest.mark.e2e def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None: """Article with comments_enabled=False must not show the comments section.""" @@ -52,8 +100,7 @@ def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> Non assert response is not None and response.status == 200, ( f"Expected 200 for e2e-no-comments article, got {response and response.status}" ) - # Confirm we're on the right page expect(page.get_by_role("heading", level=1)).to_have_text("No Comments Article") - # Comments section must be absent — exact=True prevents matching "No Comments Article" h1 expect(page.get_by_role("heading", name="Comments", exact=True)).to_have_count(0) expect(page.get_by_role("button", name="Post comment")).to_have_count(0) + diff --git a/e2e/test_home.py b/e2e/test_home.py index 73077fb..a44db91 100644 --- a/e2e/test_home.py +++ b/e2e/test_home.py @@ -37,12 +37,11 @@ def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None: @pytest.mark.e2e -def test_newsletter_form_in_nav(page: Page, base_url: str) -> None: +def test_nav_subscribe_cta_present(page: Page, base_url: str) -> None: page.goto(f"{base_url}/", wait_until="networkidle") - # The nav contains a newsletter form with an email input nav = page.locator("nav") - expect(nav.locator('input[type="email"]')).to_be_visible() - expect(nav.get_by_role("button", name="Subscribe")).to_be_visible() + # Nav has a Subscribe CTA link (not a form — wireframe spec) + expect(nav.get_by_role("link", name="Subscribe")).to_be_visible() @pytest.mark.e2e diff --git a/e2e/test_newsletter.py b/e2e/test_newsletter.py index 5b2042c..63372c0 100644 --- a/e2e/test_newsletter.py +++ b/e2e/test_newsletter.py @@ -7,7 +7,8 @@ from playwright.sync_api import Page, expect def _nav_newsletter_form(page: Page): - return page.locator("nav").locator("form[data-newsletter-form]") + """Return the newsletter form in the home page sidebar aside.""" + return page.locator("aside").locator("form[data-newsletter-form]").first @pytest.mark.e2e @@ -28,7 +29,7 @@ def test_subscribe_invalid_email_shows_error(page: Page, base_url: str) -> None: form = _nav_newsletter_form(page) # Disable the browser's native HTML5 email validation so the JS handler # fires and sends the bad value to the server (which returns 400). - page.evaluate("document.querySelector('nav form[data-newsletter-form]').setAttribute('novalidate', '')") + page.evaluate("document.querySelector('aside form[data-newsletter-form]').setAttribute('novalidate', '')") form.locator('input[type="email"]').fill("not-an-email") form.get_by_role("button", name="Subscribe").click() diff --git a/templates/blog/article_page.html b/templates/blog/article_page.html index de86513..07d2ab0 100644 --- a/templates/blog/article_page.html +++ b/templates/blog/article_page.html @@ -178,8 +178,7 @@ - - + {% endfor %} @@ -197,7 +196,7 @@

Post a Comment

-
+ {% csrf_token %}
@@ -217,7 +216,7 @@
- +
diff --git a/templates/components/footer.html b/templates/components/footer.html index 7ceb973..9d1e97b 100644 --- a/templates/components/footer.html +++ b/templates/components/footer.html @@ -21,9 +21,21 @@
-

Newsletter

-

Get weekly AI tool reviews.

- {% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %} +

Connect

+
diff --git a/templates/components/nav.html b/templates/components/nav.html index 1dd3019..72b4f77 100644 --- a/templates/components/nav.html +++ b/templates/components/nav.html @@ -14,15 +14,7 @@ Home Articles About - + Subscribe
@@ -49,7 +41,7 @@ - +

diff --git a/templates/components/newsletter_form.html b/templates/components/newsletter_form.html index f650265..55ddcca 100644 --- a/templates/components/newsletter_form.html +++ b/templates/components/newsletter_form.html @@ -3,7 +3,7 @@ - +