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 @@
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
+
@@ -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 @@
-
+