Add Docker-executed pytest suite with >90% coverage

This commit is contained in:
Codex_B
2026-02-28 11:53:05 +00:00
parent b5f0f40c4c
commit 8970f4d8de
25 changed files with 587 additions and 0 deletions

View File

View File

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

View File

View File

@@ -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", "<p>Hello world</p>")]
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 = "<p>Body</p>"
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

View File

@@ -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")

View File

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

View File

@@ -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", "<p>one two three</p>"), ("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")

View File

@@ -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-")

View File

@@ -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", "<p>body</p>")],
)
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", "<p>body</p>")],
)
index.add_child(instance=main)
main.save_revision().publish()
related = ArticlePage(
title="Related",
slug="related",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
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

View File

View File

@@ -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", "<p>body</p>")])
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()

View File

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

View File

@@ -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", "<p>body</p>")])
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", "<p>body</p>")],
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

View File

View File

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

View File

@@ -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="<p>x</p>", 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

View File

@@ -0,0 +1,2 @@
def test_smoke():
assert 1 == 1

View File

@@ -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="<p>x</p>")
legal_index.add_child(instance=legal)
legal.save_revision().publish()
resp = client.get("/")
assert resp.status_code == 200

View File

View File

@@ -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="<p>x</p>")
legal_index.add_child(instance=legal)
legal.save_revision().publish()
resp = client.get("/legal/privacy-policy/")
assert resp.status_code == 200

View File

@@ -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() == []

View File

View File

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

View File

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

52
conftest.py Normal file
View File

@@ -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", "<p>body words</p>")],
)
article_index.add_child(instance=article)
article.save_revision().publish()
return article