Issue #61: Strengthen auto-generation for slug, summary, and SEO fields. - ArticlePage.save() now auto-generates slug from title when empty - ArticlePage.save() auto-populates search_description from summary - Admin form also auto-populates search_description from summary Issue #63: Replace manual TagMetadata colour assignment with deterministic hash-based auto-colour. Tags get a consistent colour from a 12-entry palette without needing a TagMetadata snippet. TagMetadata still works as an explicit override. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
496 lines
17 KiB
Python
496 lines
17 KiB
Python
from datetime import timedelta
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from django.contrib import messages
|
|
from django.contrib.messages.storage.fallback import FallbackStorage
|
|
from django.contrib.sessions.middleware import SessionMiddleware
|
|
from django.test import override_settings
|
|
from django.utils import timezone
|
|
|
|
from apps.blog.models import ArticleIndexPage, ArticlePage, ArticlePageAdminForm, Category
|
|
from apps.blog.tests.factories import AuthorFactory
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_published_date_auto_set_on_first_publish(home_page):
|
|
"""published_date should be auto-populated from first_published_at on first publish."""
|
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
|
home_page.add_child(instance=index)
|
|
author = AuthorFactory()
|
|
article = ArticlePage(
|
|
title="Auto Date",
|
|
slug="auto-date",
|
|
author=author,
|
|
summary="summary",
|
|
body=[("rich_text", "<p>body</p>")],
|
|
)
|
|
index.add_child(instance=article)
|
|
article.save_revision().publish()
|
|
article.refresh_from_db()
|
|
assert article.published_date is not None
|
|
assert article.published_date == article.first_published_at
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_published_date_preserved_when_explicitly_set(home_page):
|
|
"""An explicitly set published_date should not be overwritten on save."""
|
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
|
home_page.add_child(instance=index)
|
|
author = AuthorFactory()
|
|
custom_date = timezone.now() - timedelta(days=30)
|
|
article = ArticlePage(
|
|
title="Custom Date",
|
|
slug="custom-date",
|
|
author=author,
|
|
summary="summary",
|
|
body=[("rich_text", "<p>body</p>")],
|
|
published_date=custom_date,
|
|
)
|
|
index.add_child(instance=article)
|
|
article.save_revision().publish()
|
|
article.refresh_from_db()
|
|
assert article.published_date == custom_date
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_homepage_orders_articles_by_published_date(home_page):
|
|
"""HomePage context should list articles ordered by -published_date."""
|
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
|
home_page.add_child(instance=index)
|
|
author = AuthorFactory()
|
|
|
|
older = ArticlePage(
|
|
title="Older",
|
|
slug="older",
|
|
author=author,
|
|
summary="s",
|
|
body=[("rich_text", "<p>body</p>")],
|
|
published_date=timezone.now() - timedelta(days=10),
|
|
)
|
|
index.add_child(instance=older)
|
|
older.save_revision().publish()
|
|
|
|
newer = ArticlePage(
|
|
title="Newer",
|
|
slug="newer",
|
|
author=author,
|
|
summary="s",
|
|
body=[("rich_text", "<p>body</p>")],
|
|
published_date=timezone.now(),
|
|
)
|
|
index.add_child(instance=newer)
|
|
newer.save_revision().publish()
|
|
|
|
ctx = home_page.get_context(type("Req", (), {"GET": {}})())
|
|
titles = [a.title for a in ctx["latest_articles"]]
|
|
assert titles.index("Newer") < titles.index("Older")
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_index_orders_by_published_date(home_page, rf):
|
|
"""ArticleIndexPage.get_articles should order by -published_date."""
|
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
|
home_page.add_child(instance=index)
|
|
author = AuthorFactory()
|
|
|
|
old = ArticlePage(
|
|
title="Old",
|
|
slug="old",
|
|
author=author,
|
|
summary="s",
|
|
body=[("rich_text", "<p>b</p>")],
|
|
published_date=timezone.now() - timedelta(days=5),
|
|
)
|
|
index.add_child(instance=old)
|
|
old.save_revision().publish()
|
|
|
|
new = ArticlePage(
|
|
title="New",
|
|
slug="new",
|
|
author=author,
|
|
summary="s",
|
|
body=[("rich_text", "<p>b</p>")],
|
|
published_date=timezone.now(),
|
|
)
|
|
index.add_child(instance=new)
|
|
new.save_revision().publish()
|
|
|
|
articles = list(index.get_articles())
|
|
assert articles[0].title == "New"
|
|
assert articles[1].title == "Old"
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_feed_uses_published_date(article_page):
|
|
"""RSS feed item_pubdate should use published_date."""
|
|
from apps.blog.feeds import AllArticlesFeed
|
|
|
|
feed = AllArticlesFeed()
|
|
assert feed.item_pubdate(article_page) == article_page.published_date
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
|
def test_articles_listing_viewset_loads(client, django_user_model, home_page):
|
|
"""The Articles PageListingViewSet index page should load."""
|
|
admin = django_user_model.objects.create_superuser(
|
|
username="admin", email="admin@example.com", password="admin-pass"
|
|
)
|
|
client.force_login(admin)
|
|
response = client.get("/cms/articles/")
|
|
assert response.status_code == 200
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
|
def test_articles_listing_shows_articles(client, django_user_model, home_page):
|
|
"""The Articles listing should show existing articles."""
|
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
|
home_page.add_child(instance=index)
|
|
author = AuthorFactory()
|
|
article = ArticlePage(
|
|
title="Listed Article",
|
|
slug="listed-article",
|
|
author=author,
|
|
summary="summary",
|
|
body=[("rich_text", "<p>body</p>")],
|
|
)
|
|
index.add_child(instance=article)
|
|
article.save_revision().publish()
|
|
|
|
admin = django_user_model.objects.create_superuser(
|
|
username="admin", email="admin@example.com", password="admin-pass"
|
|
)
|
|
client.force_login(admin)
|
|
response = client.get("/cms/articles/")
|
|
assert response.status_code == 200
|
|
assert "Listed Article" in response.content.decode()
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
|
def test_dashboard_panel_renders(client, django_user_model, home_page):
|
|
"""The Wagtail admin dashboard should include the articles summary panel."""
|
|
admin = django_user_model.objects.create_superuser(
|
|
username="admin", email="admin@example.com", password="admin-pass"
|
|
)
|
|
client.force_login(admin)
|
|
response = client.get("/cms/")
|
|
assert response.status_code == 200
|
|
content = response.content.decode()
|
|
assert "Articles overview" in content
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
|
def test_dashboard_panel_shows_drafts(client, django_user_model, home_page):
|
|
"""Dashboard panel should list draft articles."""
|
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
|
home_page.add_child(instance=index)
|
|
author = AuthorFactory()
|
|
draft = ArticlePage(
|
|
title="My Draft Post",
|
|
slug="draft-post",
|
|
author=author,
|
|
summary="summary",
|
|
body=[("rich_text", "<p>body</p>")],
|
|
)
|
|
index.add_child(instance=draft)
|
|
draft.save_revision() # save revision but don't publish
|
|
|
|
admin = django_user_model.objects.create_superuser(
|
|
username="admin", email="admin@example.com", password="admin-pass"
|
|
)
|
|
client.force_login(admin)
|
|
response = client.get("/cms/")
|
|
content = response.content.decode()
|
|
assert "My Draft Post" in content
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
|
def test_article_edit_page_has_tabbed_interface(client, django_user_model, home_page):
|
|
"""ArticlePage editor should have tabbed panels (Content, Metadata, Publishing, SEO)."""
|
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
|
home_page.add_child(instance=index)
|
|
author = AuthorFactory()
|
|
article = ArticlePage(
|
|
title="Tabbed",
|
|
slug="tabbed",
|
|
author=author,
|
|
summary="summary",
|
|
body=[("rich_text", "<p>body</p>")],
|
|
)
|
|
index.add_child(instance=article)
|
|
article.save_revision().publish()
|
|
|
|
admin = django_user_model.objects.create_superuser(
|
|
username="admin", email="admin@example.com", password="admin-pass"
|
|
)
|
|
client.force_login(admin)
|
|
response = client.get(f"/cms/pages/{article.pk}/edit/")
|
|
content = response.content.decode()
|
|
assert response.status_code == 200
|
|
assert "Content" in content
|
|
assert "Metadata" in content
|
|
assert "Publishing" in content
|
|
assert "SEO" in content
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
|
def test_articles_listing_has_status_filter(client, django_user_model, home_page):
|
|
"""The Articles listing should accept status filter parameter."""
|
|
admin = django_user_model.objects.create_superuser(
|
|
username="admin", email="admin@example.com", password="admin-pass"
|
|
)
|
|
client.force_login(admin)
|
|
response = client.get("/cms/articles/?status=live")
|
|
assert response.status_code == 200
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
|
def test_articles_listing_has_tag_filter(client, django_user_model, home_page):
|
|
"""The Articles listing should accept tag filter parameter."""
|
|
admin = django_user_model.objects.create_superuser(
|
|
username="admin", email="admin@example.com", password="admin-pass"
|
|
)
|
|
client.force_login(admin)
|
|
response = client.get("/cms/articles/?tag=1")
|
|
assert response.status_code == 200
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_listing_default_ordering():
|
|
"""ArticlePageListingViewSet should default to -published_date ordering."""
|
|
from apps.blog.wagtail_hooks import ArticlePageListingViewSet
|
|
|
|
assert ArticlePageListingViewSet.default_ordering == "-published_date"
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_search_fields_include_summary():
|
|
"""ArticlePage.search_fields should index the summary field."""
|
|
field_names = [
|
|
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
|
|
]
|
|
assert "summary" in field_names
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_admin_form_relaxes_initial_required_fields(article_index, django_user_model):
|
|
"""Slug/author/category/summary should not block initial draft validation."""
|
|
user = django_user_model.objects.create_user(
|
|
username="writer",
|
|
email="writer@example.com",
|
|
password="writer-pass",
|
|
)
|
|
form_class = ArticlePage.get_edit_handler().get_form_class()
|
|
form = form_class(parent_page=article_index, for_user=user)
|
|
|
|
assert form.fields["slug"].required is False
|
|
assert form.fields["author"].required is False
|
|
assert form.fields["category"].required is False
|
|
assert form.fields["summary"].required is False
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_admin_form_clean_applies_defaults(article_index, django_user_model, monkeypatch):
|
|
"""Form clean should populate defaults before parent validation runs."""
|
|
user = django_user_model.objects.create_user(
|
|
username="writer",
|
|
email="writer@example.com",
|
|
password="writer-pass",
|
|
first_name="Writer",
|
|
last_name="User",
|
|
)
|
|
form_class = ArticlePage.get_edit_handler().get_form_class()
|
|
form = form_class(parent_page=article_index, for_user=user)
|
|
|
|
body = [
|
|
SimpleNamespace(block_type="code", value=SimpleNamespace(raw_code="print('ignore')")),
|
|
SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="<p>Hello world body text.</p>")),
|
|
]
|
|
form.cleaned_data = {
|
|
"title": "Auto Defaults Title",
|
|
"slug": "",
|
|
"author": None,
|
|
"category": None,
|
|
"summary": "",
|
|
"body": body,
|
|
}
|
|
observed = {}
|
|
|
|
def fake_super_clean(_self):
|
|
observed["slug_before_parent_clean"] = _self.cleaned_data.get("slug")
|
|
return _self.cleaned_data
|
|
|
|
mro = form.__class__.__mro__
|
|
super_form_class = mro[mro.index(ArticlePageAdminForm) + 1]
|
|
monkeypatch.setattr(super_form_class, "clean", fake_super_clean)
|
|
cleaned = form.clean()
|
|
|
|
assert observed["slug_before_parent_clean"] == "auto-defaults-title"
|
|
assert cleaned["slug"] == "auto-defaults-title"
|
|
assert cleaned["author"] is not None
|
|
assert cleaned["author"].user_id == user.id
|
|
assert cleaned["category"] is not None
|
|
assert cleaned["category"].slug == "general"
|
|
assert cleaned["summary"] == "Hello world body text."
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_seo_tab_fields_not_duplicated():
|
|
"""SEO tab should include each promote/SEO field only once."""
|
|
handler = ArticlePage.get_edit_handler()
|
|
seo_tab = next(panel for panel in handler.children if panel.heading == "SEO")
|
|
|
|
def flatten_field_names(panel):
|
|
names = []
|
|
for child in panel.children:
|
|
if hasattr(child, "field_name"):
|
|
names.append(child.field_name)
|
|
else:
|
|
names.extend(flatten_field_names(child))
|
|
return names
|
|
|
|
field_names = flatten_field_names(seo_tab)
|
|
assert field_names.count("slug") == 1
|
|
assert field_names.count("seo_title") == 1
|
|
assert field_names.count("search_description") == 1
|
|
assert field_names.count("show_in_menus") == 1
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_save_autogenerates_summary_when_missing(article_index):
|
|
"""Model save fallback should generate summary from prose blocks."""
|
|
category = Category.objects.create(name="Guides", slug="guides")
|
|
author = AuthorFactory()
|
|
article = ArticlePage(
|
|
title="Summary Auto",
|
|
slug="summary-auto",
|
|
author=author,
|
|
category=category,
|
|
summary="",
|
|
body=[
|
|
("code", {"language": "python", "filename": "", "raw_code": "print('skip')"}),
|
|
("rich_text", "<p>This should become the summary text.</p>"),
|
|
],
|
|
)
|
|
|
|
article_index.add_child(instance=article)
|
|
article.save()
|
|
|
|
assert article.summary == "This should become the summary text."
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_category_verbose_name_plural():
|
|
"""Category Meta should define verbose_name_plural as 'categories'."""
|
|
assert Category._meta.verbose_name_plural == "categories"
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
|
|
def test_snippet_category_listing_shows_categories(client, django_user_model):
|
|
"""Categories created in the database should appear in the Snippets listing."""
|
|
Category.objects.create(name="Reviews", slug="reviews")
|
|
Category.objects.create(name="Tutorials", slug="tutorials")
|
|
|
|
admin = django_user_model.objects.create_superuser(
|
|
username="admin-cat", email="admin-cat@example.com", password="admin-pass"
|
|
)
|
|
client.force_login(admin)
|
|
response = client.get("/cms/snippets/blog/category/")
|
|
content = response.content.decode()
|
|
|
|
assert response.status_code == 200
|
|
assert "Reviews" in content
|
|
assert "Tutorials" in content
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_admin_form_clean_auto_populates_search_description(article_index, django_user_model, monkeypatch):
|
|
"""Form clean should auto-populate search_description from summary."""
|
|
user = django_user_model.objects.create_user(
|
|
username="writer",
|
|
email="writer@example.com",
|
|
password="writer-pass",
|
|
first_name="Writer",
|
|
last_name="User",
|
|
)
|
|
form_class = ArticlePage.get_edit_handler().get_form_class()
|
|
form = form_class(parent_page=article_index, for_user=user)
|
|
|
|
body = [
|
|
SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="<p>Article body text.</p>")),
|
|
]
|
|
form.cleaned_data = {
|
|
"title": "SEO Test",
|
|
"slug": "",
|
|
"author": None,
|
|
"category": None,
|
|
"summary": "",
|
|
"search_description": "",
|
|
"body": body,
|
|
}
|
|
|
|
mro = form.__class__.__mro__
|
|
super_form_class = mro[mro.index(ArticlePageAdminForm) + 1]
|
|
monkeypatch.setattr(super_form_class, "clean", lambda _self: _self.cleaned_data)
|
|
cleaned = form.clean()
|
|
|
|
assert cleaned["summary"] == "Article body text."
|
|
assert cleaned["search_description"] == "Article body text."
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_admin_form_preserves_explicit_search_description(article_index, django_user_model, monkeypatch):
|
|
"""Form clean should not overwrite an explicit search_description."""
|
|
user = django_user_model.objects.create_user(
|
|
username="writer2",
|
|
email="writer2@example.com",
|
|
password="writer-pass",
|
|
)
|
|
form_class = ArticlePage.get_edit_handler().get_form_class()
|
|
form = form_class(parent_page=article_index, for_user=user)
|
|
|
|
body = [
|
|
SimpleNamespace(block_type="rich_text", value=SimpleNamespace(source="<p>Body.</p>")),
|
|
]
|
|
form.cleaned_data = {
|
|
"title": "SEO Explicit Test",
|
|
"slug": "seo-explicit-test",
|
|
"author": None,
|
|
"category": None,
|
|
"summary": "My summary.",
|
|
"search_description": "Custom SEO text.",
|
|
"body": body,
|
|
}
|
|
|
|
mro = form.__class__.__mro__
|
|
super_form_class = mro[mro.index(ArticlePageAdminForm) + 1]
|
|
monkeypatch.setattr(super_form_class, "clean", lambda _self: _self.cleaned_data)
|
|
cleaned = form.clean()
|
|
|
|
assert cleaned["search_description"] == "Custom SEO text."
|
|
|
|
|
|
@pytest.mark.django_db
|
|
def test_article_page_omits_admin_messages_on_frontend(article_page, rf):
|
|
"""Frontend templates should not render admin session messages."""
|
|
request = rf.get(article_page.url)
|
|
SessionMiddleware(lambda req: None).process_request(request)
|
|
request.session.save()
|
|
setattr(request, "_messages", FallbackStorage(request))
|
|
messages.success(request, "Page 'Test' has been published.")
|
|
|
|
response = article_page.serve(request)
|
|
response.render()
|
|
content = response.content.decode()
|
|
|
|
assert "Page 'Test' has been published." not in content
|
|
assert 'aria-label="Messages"' not in content
|