diff --git a/apps/authors/tests/__init__.py b/apps/authors/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authors/tests/test_models.py b/apps/authors/tests/test_models.py new file mode 100644 index 0000000..63f9fdf --- /dev/null +++ b/apps/authors/tests/test_models.py @@ -0,0 +1,16 @@ +import pytest + +from apps.authors.models import Author + + +@pytest.mark.django_db +def test_author_create_and_social_links(): + author = Author.objects.create(name="Mark", slug="mark", twitter_url="https://x.com/mark") + assert str(author) == "Mark" + assert author.get_social_links() == {"twitter": "https://x.com/mark"} + + +@pytest.mark.django_db +def test_author_user_nullable(): + author = Author.objects.create(name="No User", slug="no-user") + assert author.user is None diff --git a/apps/blog/tests/__init__.py b/apps/blog/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/tests/factories.py b/apps/blog/tests/factories.py new file mode 100644 index 0000000..c3666d3 --- /dev/null +++ b/apps/blog/tests/factories.py @@ -0,0 +1,64 @@ +import factory +import wagtail_factories +from django.utils import timezone +from taggit.models import Tag +from wagtail.models import Page + +from apps.authors.models import Author +from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata +from apps.legal.models import LegalIndexPage, LegalPage + + +class AuthorFactory(factory.django.DjangoModelFactory): + class Meta: + model = Author + + name = factory.Sequence(lambda n: f"Author {n}") + slug = factory.Sequence(lambda n: f"author-{n}") + + +class HomePageFactory(wagtail_factories.PageFactory): + class Meta: + model = HomePage + + +class ArticleIndexPageFactory(wagtail_factories.PageFactory): + class Meta: + model = ArticleIndexPage + + +class ArticlePageFactory(wagtail_factories.PageFactory): + class Meta: + model = ArticlePage + + title = factory.Sequence(lambda n: f"Article {n}") + slug = factory.Sequence(lambda n: f"article-{n}") + author = factory.SubFactory(AuthorFactory) + summary = "Summary" + body = [("rich_text", "

Hello world

")] + first_published_at = factory.LazyFunction(timezone.now) + + +class LegalIndexPageFactory(wagtail_factories.PageFactory): + class Meta: + model = LegalIndexPage + + +class LegalPageFactory(wagtail_factories.PageFactory): + class Meta: + model = LegalPage + + title = factory.Sequence(lambda n: f"Legal {n}") + slug = factory.Sequence(lambda n: f"legal-{n}") + body = "

Body

" + last_updated = factory.Faker("date_object") + + +def root_page(): + return Page.get_first_root_node() + + +def create_tag_with_meta(name: str, colour: str = "neutral"): + tag, _ = Tag.objects.get_or_create(name=name, slug=name) + TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": colour}) + return tag diff --git a/apps/blog/tests/test_feeds.py b/apps/blog/tests/test_feeds.py new file mode 100644 index 0000000..0fbfd76 --- /dev/null +++ b/apps/blog/tests/test_feeds.py @@ -0,0 +1,8 @@ +import pytest + + +@pytest.mark.django_db +def test_feed_endpoint(client): + resp = client.get("/feed/") + assert resp.status_code == 200 + assert resp["Content-Type"].startswith("application/rss+xml") diff --git a/apps/blog/tests/test_feeds_more.py b/apps/blog/tests/test_feeds_more.py new file mode 100644 index 0000000..68da99d --- /dev/null +++ b/apps/blog/tests/test_feeds_more.py @@ -0,0 +1,18 @@ +import pytest + +from apps.blog.feeds import AllArticlesFeed + + +@pytest.mark.django_db +def test_all_feed_methods(article_page): + feed = AllArticlesFeed() + assert feed.item_title(article_page) == article_page.title + assert article_page.summary in feed.item_description(article_page) + assert article_page.author.name == feed.item_author_name(article_page) + assert feed.item_link(article_page).startswith("http") + + +@pytest.mark.django_db +def test_tag_feed_not_found(client): + resp = client.get("/feed/tag/does-not-exist/") + assert resp.status_code == 404 diff --git a/apps/blog/tests/test_models.py b/apps/blog/tests/test_models.py new file mode 100644 index 0000000..662c62e --- /dev/null +++ b/apps/blog/tests/test_models.py @@ -0,0 +1,42 @@ +import pytest +from django.db import IntegrityError +from taggit.models import Tag + +from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata +from apps.blog.tests.factories import AuthorFactory + + +@pytest.mark.django_db +def test_home_page_creation(home_page): + assert HomePage.objects.count() == 1 + + +@pytest.mark.django_db +def test_article_index_parent_restriction(): + assert ArticleIndexPage.parent_page_types == ["blog.HomePage"] + + +@pytest.mark.django_db +def test_article_compute_read_time_excludes_code(home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="A", + slug="a", + author=author, + summary="s", + body=[("rich_text", "

one two three

"), ("code", {"language": "python", "raw_code": "x y z"})], + ) + index.add_child(instance=article) + article.save() + assert article.read_time_mins == 1 + + +@pytest.mark.django_db +def test_tag_metadata_css_and_uniqueness(): + tag = Tag.objects.create(name="llms", slug="llms") + meta = TagMetadata.objects.create(tag=tag, colour="cyan") + assert meta.get_css_classes()["bg"].startswith("bg-cyan") + with pytest.raises(IntegrityError): + TagMetadata.objects.create(tag=tag, colour="pink") diff --git a/apps/blog/tests/test_more_models.py b/apps/blog/tests/test_more_models.py new file mode 100644 index 0000000..75e0e8e --- /dev/null +++ b/apps/blog/tests/test_more_models.py @@ -0,0 +1,27 @@ +import pytest + +from apps.blog.models import TagMetadata + + +@pytest.mark.django_db +def test_home_context_lists_articles(home_page, article_page): + ctx = home_page.get_context(type("Req", (), {"GET": {}})()) + assert "latest_articles" in ctx + + +@pytest.mark.django_db +def test_index_context_handles_page_values(article_index, article_page, rf): + request = rf.get("/", {"page": "notanumber"}) + ctx = article_index.get_context(request) + assert ctx["articles"].number == 1 + + +@pytest.mark.django_db +def test_get_related_articles_fallback(article_page, article_index): + related = article_page.get_related_articles() + assert isinstance(related, list) + + +def test_tag_metadata_fallback_classes(): + css = TagMetadata.get_fallback_css() + assert css["bg"].startswith("bg-") diff --git a/apps/blog/tests/test_views.py b/apps/blog/tests/test_views.py new file mode 100644 index 0000000..5143806 --- /dev/null +++ b/apps/blog/tests/test_views.py @@ -0,0 +1,61 @@ +import pytest + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory + + +@pytest.mark.django_db +def test_homepage_render(client, home_page): + resp = client.get("/") + assert resp.status_code == 200 + + +@pytest.mark.django_db +def test_article_index_pagination_and_tag_filter(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + for n in range(14): + article = ArticlePage( + title=f"A{n}", + slug=f"a{n}", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.get("/articles/?page=2") + assert resp.status_code == 200 + assert resp.context["articles"].number == 2 + + +@pytest.mark.django_db +def test_article_page_related_context(client, home_page): + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + main = ArticlePage( + title="Main", + slug="main", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + ) + index.add_child(instance=main) + main.save_revision().publish() + + related = ArticlePage( + title="Related", + slug="related", + author=author, + summary="summary", + body=[("rich_text", "

body

")], + ) + index.add_child(instance=related) + related.save_revision().publish() + + resp = client.get("/articles/main/") + assert resp.status_code == 200 + assert "related_articles" in resp.context diff --git a/apps/comments/tests/__init__.py b/apps/comments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/comments/tests/test_models.py b/apps/comments/tests/test_models.py new file mode 100644 index 0000000..94e27b9 --- /dev/null +++ b/apps/comments/tests/test_models.py @@ -0,0 +1,40 @@ +import pytest +from django.core.exceptions import ValidationError + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory +from apps.comments.models import Comment + + +def create_article(home): + index = ArticleIndexPage(title="Articles", slug="articles") + home.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "

body

")]) + index.add_child(instance=article) + article.save_revision().publish() + return article + + +@pytest.mark.django_db +def test_comment_defaults_and_absolute_url(home_page): + article = create_article(home_page) + comment = Comment.objects.create(article=article, author_name="N", author_email="n@example.com", body="hello") + assert comment.is_approved is False + assert comment.get_absolute_url().endswith(f"#comment-{comment.id}") + + +@pytest.mark.django_db +def test_reply_depth_validation(home_page): + article = create_article(home_page) + parent = Comment.objects.create(article=article, author_name="P", author_email="p@example.com", body="p") + child = Comment.objects.create( + article=article, + author_name="C", + author_email="c@example.com", + body="c", + parent=parent, + ) + nested = Comment(article=article, author_name="X", author_email="x@example.com", body="x", parent=child) + with pytest.raises(ValidationError): + nested.clean() diff --git a/apps/comments/tests/test_more.py b/apps/comments/tests/test_more.py new file mode 100644 index 0000000..94fd369 --- /dev/null +++ b/apps/comments/tests/test_more.py @@ -0,0 +1,26 @@ +import pytest +from django.core.cache import cache + +from apps.comments.forms import CommentForm + + +@pytest.mark.django_db +def test_comment_form_rejects_blank_body(): + form = CommentForm(data={"author_name": "A", "author_email": "a@a.com", "body": " ", "article_id": 1}) + assert not form.is_valid() + + +@pytest.mark.django_db +def test_comment_rate_limit(client, article_page): + cache.clear() + payload = { + "article_id": article_page.id, + "author_name": "T", + "author_email": "t@example.com", + "body": "Hi", + "honeypot": "", + } + for _ in range(3): + client.post("/comments/post/", payload) + resp = client.post("/comments/post/", payload) + assert resp.status_code == 429 diff --git a/apps/comments/tests/test_views.py b/apps/comments/tests/test_views.py new file mode 100644 index 0000000..af8e546 --- /dev/null +++ b/apps/comments/tests/test_views.py @@ -0,0 +1,61 @@ +import pytest +from django.core.cache import cache + +from apps.blog.models import ArticleIndexPage, ArticlePage +from apps.blog.tests.factories import AuthorFactory +from apps.comments.models import Comment + + +@pytest.mark.django_db +def test_comment_post_flow(client, home_page): + cache.clear() + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "

body

")]) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.post( + "/comments/post/", + { + "article_id": article.id, + "author_name": "Test", + "author_email": "test@example.com", + "body": "Hello", + "honeypot": "", + }, + ) + assert resp.status_code == 302 + assert Comment.objects.count() == 1 + + +@pytest.mark.django_db +def test_comment_post_rejected_when_comments_disabled(client, home_page): + cache.clear() + index = ArticleIndexPage(title="Articles", slug="articles") + home_page.add_child(instance=index) + author = AuthorFactory() + article = ArticlePage( + title="A", + slug="a", + author=author, + summary="s", + body=[("rich_text", "

body

")], + comments_enabled=False, + ) + index.add_child(instance=article) + article.save_revision().publish() + + resp = client.post( + "/comments/post/", + { + "article_id": article.id, + "author_name": "Test", + "author_email": "test@example.com", + "body": "Hello", + "honeypot": "", + }, + ) + assert resp.status_code == 404 + assert Comment.objects.count() == 0 diff --git a/apps/core/tests/__init__.py b/apps/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/tests/test_consent.py b/apps/core/tests/test_consent.py new file mode 100644 index 0000000..5e25dd8 --- /dev/null +++ b/apps/core/tests/test_consent.py @@ -0,0 +1,23 @@ +import pytest +from django.http import HttpRequest, HttpResponse + +from apps.core.consent import CONSENT_COOKIE_NAME, ConsentService + + +@pytest.mark.django_db +def test_consent_round_trip(rf): + request = HttpRequest() + response = HttpResponse() + ConsentService.set_consent(response, analytics=True, advertising=False) + cookie = response.cookies[CONSENT_COOKIE_NAME].value + request.COOKIES[CONSENT_COOKIE_NAME] = cookie + state = ConsentService.get_consent(request) + assert state.analytics is True + assert state.advertising is False + + +@pytest.mark.django_db +def test_consent_post_view(client): + resp = client.post("/consent/", {"accept_all": "1"}, follow=False) + assert resp.status_code == 302 + assert CONSENT_COOKIE_NAME in resp.cookies diff --git a/apps/core/tests/test_more.py b/apps/core/tests/test_more.py new file mode 100644 index 0000000..b430ead --- /dev/null +++ b/apps/core/tests/test_more.py @@ -0,0 +1,50 @@ + +import pytest +from django.template import Context +from django.test import RequestFactory +from taggit.models import Tag +from wagtail.models import Site + +from apps.core.context_processors import site_settings +from apps.core.templatetags import core_tags +from apps.core.templatetags.seo_tags import article_json_ld +from apps.legal.models import LegalIndexPage, LegalPage + + +@pytest.mark.django_db +def test_context_processor_returns_sitesettings(home_page): + rf = RequestFactory() + request = rf.get("/") + request.site = Site.find_for_request(request) + data = site_settings(request) + assert "site_settings" in data + + +@pytest.mark.django_db +def test_get_tag_css_fallback(): + tag = Tag.objects.create(name="x", slug="x") + value = core_tags.get_tag_css(tag) + assert "bg-zinc" in value + + +@pytest.mark.django_db +def test_get_legal_pages_tag_callable(home_page): + legal_index = LegalIndexPage(title="Legal", slug="legal") + home_page.add_child(instance=legal_index) + legal = LegalPage(title="Privacy", slug="privacy-policy", body="

x

", last_updated="2026-01-01") + legal_index.add_child(instance=legal) + legal.save_revision().publish() + + rf = RequestFactory() + request = rf.get("/") + pages = core_tags.get_legal_pages({"request": request}) + assert pages.count() >= 1 + + +@pytest.mark.django_db +def test_article_json_ld_contains_headline(article_page, rf): + request = rf.get("/") + request.site = Site.objects.filter(is_default_site=True).first() + result = article_json_ld(Context({"request": request}), article_page) + assert "application/ld+json" in result + assert article_page.title in result diff --git a/apps/core/tests/test_smoke.py b/apps/core/tests/test_smoke.py new file mode 100644 index 0000000..353204d --- /dev/null +++ b/apps/core/tests/test_smoke.py @@ -0,0 +1,2 @@ +def test_smoke(): + assert 1 == 1 diff --git a/apps/core/tests/test_tags.py b/apps/core/tests/test_tags.py new file mode 100644 index 0000000..5ccabb5 --- /dev/null +++ b/apps/core/tests/test_tags.py @@ -0,0 +1,15 @@ +import pytest + +from apps.legal.models import LegalIndexPage, LegalPage + + +@pytest.mark.django_db +def test_get_legal_pages_tag(client, home_page): + legal_index = LegalIndexPage(title="Legal", slug="legal") + home_page.add_child(instance=legal_index) + legal = LegalPage(title="Privacy", slug="privacy-policy", last_updated="2026-01-01", body="

x

") + legal_index.add_child(instance=legal) + legal.save_revision().publish() + + resp = client.get("/") + assert resp.status_code == 200 diff --git a/apps/legal/tests/__init__.py b/apps/legal/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/legal/tests/test_models.py b/apps/legal/tests/test_models.py new file mode 100644 index 0000000..ec82f02 --- /dev/null +++ b/apps/legal/tests/test_models.py @@ -0,0 +1,23 @@ +import pytest + +from apps.legal.models import LegalIndexPage, LegalPage + + +@pytest.mark.django_db +def test_legal_index_redirects(client, home_page): + legal_index = LegalIndexPage(title="Legal", slug="legal") + home_page.add_child(instance=legal_index) + legal_index.save_revision().publish() + resp = client.get("/legal/") + assert resp.status_code == 302 + + +@pytest.mark.django_db +def test_legal_page_render(client, home_page): + legal_index = LegalIndexPage(title="Legal", slug="legal") + home_page.add_child(instance=legal_index) + legal = LegalPage(title="Privacy", slug="privacy-policy", last_updated="2026-01-01", body="

x

") + legal_index.add_child(instance=legal) + legal.save_revision().publish() + resp = client.get("/legal/privacy-policy/") + assert resp.status_code == 200 diff --git a/apps/legal/tests/test_more.py b/apps/legal/tests/test_more.py new file mode 100644 index 0000000..44e68ba --- /dev/null +++ b/apps/legal/tests/test_more.py @@ -0,0 +1,8 @@ +import pytest + +from apps.legal.models import LegalIndexPage + + +@pytest.mark.django_db +def test_legal_index_sitemap_urls_empty(): + assert LegalIndexPage().get_sitemap_urls() == [] diff --git a/apps/newsletter/tests/__init__.py b/apps/newsletter/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/newsletter/tests/test_more.py b/apps/newsletter/tests/test_more.py new file mode 100644 index 0000000..3ec368e --- /dev/null +++ b/apps/newsletter/tests/test_more.py @@ -0,0 +1,14 @@ +import pytest + +from apps.newsletter.services import ProviderSyncService +from apps.newsletter.views import confirmation_token + + +def test_confirmation_token_roundtrip(): + token = confirmation_token("x@example.com") + assert token + + +def test_provider_sync_not_implemented(): + with pytest.raises(NotImplementedError): + ProviderSyncService().sync(None) diff --git a/apps/newsletter/tests/test_views.py b/apps/newsletter/tests/test_views.py new file mode 100644 index 0000000..ac6f56d --- /dev/null +++ b/apps/newsletter/tests/test_views.py @@ -0,0 +1,37 @@ +import pytest +from django.core import signing + +from apps.newsletter.models import NewsletterSubscription + + +@pytest.mark.django_db +def test_subscribe_ok(client): + resp = client.post("/newsletter/subscribe/", {"email": "a@example.com", "source": "nav"}) + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + assert NewsletterSubscription.objects.filter(email="a@example.com").exists() + + +@pytest.mark.django_db +def test_subscribe_invalid(client): + resp = client.post("/newsletter/subscribe/", {"email": "bad"}) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_confirm_endpoint(client): + sub = NewsletterSubscription.objects.create(email="b@example.com") + token = signing.dumps(sub.email, salt="newsletter-confirm") + resp = client.get(f"/newsletter/confirm/{token}/") + assert resp.status_code == 302 + sub.refresh_from_db() + assert sub.confirmed is True + + +@pytest.mark.django_db +def test_confirm_endpoint_with_expired_token(client, monkeypatch): + sub = NewsletterSubscription.objects.create(email="c@example.com") + token = signing.dumps(sub.email, salt="newsletter-confirm") + monkeypatch.setattr("apps.newsletter.views.CONFIRMATION_TOKEN_MAX_AGE_SECONDS", -1) + resp = client.get(f"/newsletter/confirm/{token}/") + assert resp.status_code == 404 diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..0d2df7c --- /dev/null +++ b/conftest.py @@ -0,0 +1,52 @@ +import pytest +from wagtail.models import Page, Site + +from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage +from apps.blog.tests.factories import AuthorFactory + + +@pytest.fixture +def home_page(db): + root = Page.get_first_root_node() + home = HomePage(title="Home", slug=f"home-{HomePage.objects.count() + 1}") + root.add_child(instance=home) + home.save_revision().publish() + site = Site.objects.filter(is_default_site=True).first() + if site: + site.root_page = home + site.hostname = "localhost" + site.port = 80 + site.site_name = "No Hype AI" + site.save() + else: + Site.objects.create( + hostname="localhost", + root_page=home, + is_default_site=True, + site_name="No Hype AI", + port=80, + ) + return home + + +@pytest.fixture +def article_index(home_page): + index = ArticleIndexPage(title="Articles", slug=f"articles-{ArticleIndexPage.objects.count() + 1}") + home_page.add_child(instance=index) + index.save_revision().publish() + return index + + +@pytest.fixture +def article_page(article_index): + author = AuthorFactory() + article = ArticlePage( + title=f"Article {ArticlePage.objects.count() + 1}", + slug=f"article-{ArticlePage.objects.count() + 1}", + author=author, + summary="summary", + body=[("rich_text", "

body words

")], + ) + article_index.add_child(instance=article) + article.save_revision().publish() + return article