feat: replace hardcoded navigation with CMS-managed models
Replace static nav/footer links with Wagtail-managed NavigationMenuItem and SocialMediaLink orderables on SiteSettings. Unpublished pages are automatically excluded from rendering, fixing the dead-link problem. - Extend SiteSettings with site_name, tagline, footer_description, copyright_text branding fields - Add NavigationMenuItem orderable (link_page/link_url, show_in_header, show_in_footer, sort_order) with automatic live-page filtering - Add SocialMediaLink orderable with platform icon templates - New template tags: get_nav_items, get_social_links - Update nav.html and footer.html to render from CMS data - Data migration seeds existing hardcoded values for zero-change deploy - Update seed_e2e_content command for test/dev environments - 18 new tests covering models, template tags, and rendered output Closes #32 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
191
apps/core/tests/test_navigation.py
Normal file
191
apps/core/tests/test_navigation.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import pytest
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.blog.models import AboutPage, ArticleIndexPage
|
||||
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def site_with_nav(home_page):
|
||||
"""Create SiteSettings with nav items and social links for testing."""
|
||||
site = Site.objects.get(is_default_site=True)
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
|
||||
# Clear any items seeded by the data migration
|
||||
settings.navigation_items.all().delete()
|
||||
settings.social_links.all().delete()
|
||||
|
||||
# Create article index and about page
|
||||
article_index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=article_index)
|
||||
article_index.save_revision().publish()
|
||||
|
||||
about = AboutPage(
|
||||
title="About",
|
||||
slug="about",
|
||||
mission_statement="Test mission",
|
||||
body="<p>About page</p>",
|
||||
)
|
||||
home_page.add_child(instance=about)
|
||||
about.save_revision().publish()
|
||||
|
||||
# Create nav items
|
||||
NavigationMenuItem.objects.create(
|
||||
settings=settings,
|
||||
link_page=home_page,
|
||||
link_title="Home",
|
||||
show_in_header=True,
|
||||
show_in_footer=True,
|
||||
sort_order=0,
|
||||
)
|
||||
NavigationMenuItem.objects.create(
|
||||
settings=settings,
|
||||
link_page=article_index,
|
||||
link_title="Articles",
|
||||
show_in_header=True,
|
||||
show_in_footer=True,
|
||||
sort_order=1,
|
||||
)
|
||||
NavigationMenuItem.objects.create(
|
||||
settings=settings,
|
||||
link_page=about,
|
||||
link_title="About",
|
||||
show_in_header=True,
|
||||
show_in_footer=False,
|
||||
sort_order=2,
|
||||
)
|
||||
|
||||
# Social links
|
||||
SocialMediaLink.objects.create(
|
||||
settings=settings,
|
||||
platform="twitter",
|
||||
url="https://twitter.com/nohypeai",
|
||||
label="Twitter (X)",
|
||||
sort_order=0,
|
||||
)
|
||||
SocialMediaLink.objects.create(
|
||||
settings=settings,
|
||||
platform="rss",
|
||||
url="/feed/",
|
||||
label="RSS Feed",
|
||||
sort_order=1,
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestNavigationMenuItem:
|
||||
def test_live_page_is_rendered(self, site_with_nav):
|
||||
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
|
||||
assert len(items) == 3
|
||||
|
||||
def test_unpublished_page_excluded(self, site_with_nav):
|
||||
about_item = site_with_nav.navigation_items.get(link_title="About")
|
||||
about_item.link_page.unpublish()
|
||||
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
|
||||
assert len(items) == 2
|
||||
assert all(i.link_title != "About" for i in items)
|
||||
|
||||
def test_external_url_item(self, site_with_nav):
|
||||
NavigationMenuItem.objects.create(
|
||||
settings=site_with_nav,
|
||||
link_url="https://example.com",
|
||||
link_title="External",
|
||||
sort_order=10,
|
||||
)
|
||||
item = site_with_nav.navigation_items.get(link_title="External")
|
||||
assert item.is_live is True
|
||||
assert item.url == "https://example.com"
|
||||
assert item.title == "External"
|
||||
|
||||
def test_title_falls_back_to_page_title(self, site_with_nav):
|
||||
item = site_with_nav.navigation_items.get(sort_order=0)
|
||||
item.link_title = ""
|
||||
item.save()
|
||||
assert item.title == item.link_page.title
|
||||
|
||||
def test_header_footer_filtering(self, site_with_nav):
|
||||
header_items = site_with_nav.navigation_items.filter(show_in_header=True)
|
||||
footer_items = site_with_nav.navigation_items.filter(show_in_footer=True)
|
||||
assert header_items.count() == 3
|
||||
assert footer_items.count() == 2 # About excluded from footer
|
||||
|
||||
def test_sort_order_respected(self, site_with_nav):
|
||||
items = list(site_with_nav.navigation_items.all().order_by("sort_order"))
|
||||
assert [i.link_title for i in items] == ["Home", "Articles", "About"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSocialMediaLink:
|
||||
def test_display_label_from_field(self, site_with_nav):
|
||||
link = site_with_nav.social_links.get(platform="twitter")
|
||||
assert link.display_label == "Twitter (X)"
|
||||
|
||||
def test_display_label_fallback(self, site_with_nav):
|
||||
link = site_with_nav.social_links.get(platform="twitter")
|
||||
link.label = ""
|
||||
assert link.display_label == "Twitter / X"
|
||||
|
||||
def test_icon_template_path(self, site_with_nav):
|
||||
link = site_with_nav.social_links.get(platform="rss")
|
||||
assert link.icon_template == "components/icons/rss.html"
|
||||
|
||||
def test_ordering(self, site_with_nav):
|
||||
links = list(site_with_nav.social_links.all().order_by("sort_order"))
|
||||
assert [link.platform for link in links] == ["twitter", "rss"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSiteSettingsDefaults:
|
||||
def test_default_site_name(self, home_page):
|
||||
site = Site.objects.get(is_default_site=True)
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
assert settings.site_name == "NO HYPE AI"
|
||||
|
||||
def test_default_copyright(self, home_page):
|
||||
site = Site.objects.get(is_default_site=True)
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
assert settings.copyright_text == "No Hype AI. All rights reserved."
|
||||
|
||||
def test_default_tagline(self, home_page):
|
||||
site = Site.objects.get(is_default_site=True)
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
assert settings.tagline == "Honest AI tool reviews for developers."
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestNavRendering:
|
||||
def test_header_shows_nav_items(self, client, site_with_nav):
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
assert "Home" in content
|
||||
assert "Articles" in content
|
||||
assert "About" in content
|
||||
|
||||
def test_unpublished_page_not_in_header(self, client, site_with_nav):
|
||||
about_item = site_with_nav.navigation_items.get(link_title="About")
|
||||
about_item.link_page.unpublish()
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
# About should not appear as a nav link (but might appear elsewhere on page)
|
||||
assert 'href="/about/"' not in content
|
||||
|
||||
def test_footer_shows_nav_items(self, client, site_with_nav):
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
# Footer should have social links
|
||||
assert "Twitter (X)" in content
|
||||
assert "RSS Feed" in content
|
||||
|
||||
def test_footer_shows_branding(self, client, site_with_nav):
|
||||
site_with_nav.site_name = "TEST SITE"
|
||||
site_with_nav.save()
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
assert "TEST SITE" in content
|
||||
|
||||
def test_footer_shows_copyright(self, client, site_with_nav):
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
assert "No Hype AI. All rights reserved." in content
|
||||
Reference in New Issue
Block a user