test: migrate backend suites to pytest with module coverage gate
Split backend tests into unit and integration suites, add isolated fixtures and coverage enforcement, and update CI and test documentation. Cover current authentication, content, notification, payment, media, and admin flows; fix behaviors revealed by the new tests. Validate both suites and the per-module gate against local PostgreSQL.
This commit is contained in:
43
apps/blog/tests/integration/test_commands.py
Normal file
43
apps/blog/tests/integration/test_commands.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from io import StringIO
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.management import call_command
|
||||
|
||||
from apps.blog.models import Category, Post, Tag
|
||||
from apps.blog.permissions import BLOG_EDITOR_GROUP, BLOG_SUPERVISOR_GROUP
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
||||
|
||||
|
||||
def test_category_and_tag_seed_commands_support_dry_run_and_idempotence():
|
||||
output = StringIO()
|
||||
call_command("seed_blog_categories", dry_run=True, stdout=output)
|
||||
call_command("seed_blog_tags", dry_run=True, stdout=output)
|
||||
assert Category.objects.count() == 0
|
||||
assert Tag.objects.count() == 0
|
||||
call_command("seed_blog_categories", stdout=output)
|
||||
call_command("seed_blog_tags", stdout=output)
|
||||
counts = Category.objects.count(), Tag.objects.count()
|
||||
assert counts[0] > 0 and counts[1] > 0
|
||||
call_command("seed_blog_categories", stdout=output)
|
||||
call_command("seed_blog_tags", stdout=output)
|
||||
assert (Category.objects.count(), Tag.objects.count()) == counts
|
||||
|
||||
|
||||
def test_role_sync_creates_editor_and_supervisor_permissions():
|
||||
call_command("sync_blog_roles", stdout=StringIO())
|
||||
editor = Group.objects.get(name=BLOG_EDITOR_GROUP)
|
||||
supervisor = Group.objects.get(name=BLOG_SUPERVISOR_GROUP)
|
||||
assert editor.permissions.filter(codename="add_post").exists()
|
||||
assert supervisor.permissions.filter(codename="review_blog_post").exists()
|
||||
|
||||
|
||||
def test_mock_seed_creates_posts_and_can_reset(tmp_path, settings, mocker):
|
||||
settings.MEDIA_ROOT = str(tmp_path)
|
||||
mocker.patch("apps.blog.management.commands.seed_blog_mock_data.set_image_field")
|
||||
call_command("seed_blog_mock_data", stdout=StringIO())
|
||||
assert Post.objects.filter(slug__startswith="mock-").count() == 5
|
||||
call_command("seed_blog_mock_data", reset=True, stdout=StringIO())
|
||||
assert Post.objects.filter(slug__startswith="mock-").count() == 5
|
||||
309
apps/blog/tests/integration/test_public_blog.py
Normal file
309
apps/blog/tests/integration/test_public_blog.py
Normal file
@@ -0,0 +1,309 @@
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
from apps.blog.models import Category, Comment, Like, Post, PostAsset, SavedPost, Tag
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
||||
|
||||
|
||||
def test_public_blog_lists_empty_content(client):
|
||||
response = client.get("/api/blog/posts")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def test_admin_blog_requires_authentication(client):
|
||||
assert client.get("/api/blog/admin/posts").status_code == 401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blog_content(user_factory):
|
||||
author = user_factory()
|
||||
category = Category.objects.create(name="Events")
|
||||
child = Category.objects.create(name="News", parent=category)
|
||||
tag = Tag.objects.create(name="Featured")
|
||||
first = Post.objects.create(title="First story", content="**First** story", author=author,
|
||||
category=child, status=Post.StatusChoices.PUBLISHED, is_featured=True)
|
||||
first.tags.add(tag)
|
||||
second = Post.objects.create(title="Second story", content="Second content", author=author,
|
||||
category=category, status=Post.StatusChoices.PUBLISHED)
|
||||
draft = Post.objects.create(title="Private draft", content="Draft", author=author)
|
||||
return author, category, child, tag, first, second, draft
|
||||
|
||||
|
||||
def test_public_filters_posts_and_recommendations(client, blog_content):
|
||||
author, category, child, tag, first, second, draft = blog_content
|
||||
assert {post["slug"] for post in client.get("/api/blog/posts").json()} == {first.slug, second.slug}
|
||||
assert {post["slug"] for post in client.get("/api/blog/posts", {"category": category.slug}).json()} == {first.slug, second.slug}
|
||||
assert [post["slug"] for post in client.get("/api/blog/posts", {"tag": tag.slug}).json()] == [first.slug]
|
||||
assert [post["slug"] for post in client.get("/api/blog/posts", {"search": "Second"}).json()] == [second.slug]
|
||||
assert [post["slug"] for post in client.get("/api/blog/posts", {"featured": "true"}).json()] == [first.slug]
|
||||
assert {post["slug"] for post in client.get("/api/blog/posts", {"author": author.username}).json()} == {first.slug, second.slug}
|
||||
assert client.get("/api/blog/posts", {"category": "unknown"}).json() == []
|
||||
assert client.get(f"/api/blog/posts/{draft.slug}").status_code == 404
|
||||
assert client.get(f"/api/blog/posts/{first.slug}").json()["title"] == first.title
|
||||
assert [post["slug"] for post in client.get(f"/api/blog/posts/{first.slug}/recommended").json()] == [second.slug]
|
||||
filters = client.get("/api/blog/filters").json()
|
||||
assert filters["categories"][0]["children"][0]["slug"] == child.slug
|
||||
assert filters["tags"][0]["post_count"] == 1
|
||||
assert filters["authors"][0]["post_count"] == 2
|
||||
|
||||
|
||||
def test_public_taxonomy_and_comment_visibility(client, blog_content, user_factory):
|
||||
author, category, child, tag, post, _, _ = blog_content
|
||||
member = user_factory()
|
||||
approved = Comment.objects.create(post=post, author=member, content="Visible")
|
||||
hidden = Comment.objects.create(post=post, author=member, content="Hidden", is_hidden=True)
|
||||
assert [row["slug"] for row in client.get("/api/blog/categories").json()] == [category.slug, child.slug]
|
||||
assert client.get(f"/api/blog/categories/{category.slug}").json()["name"] == category.name
|
||||
assert client.get(f"/api/blog/tags/{tag.slug}").json()["name"] == tag.name
|
||||
assert [row["id"] for row in client.get(f"/api/blog/posts/{post.slug}/comments").json()] == [approved.id]
|
||||
assert hidden.id != approved.id
|
||||
|
||||
|
||||
def test_blog_interactions_and_activity(client, blog_content, user_factory, auth_headers, mocker):
|
||||
_, _, _, _, post, _, _ = blog_content
|
||||
member = user_factory()
|
||||
headers = auth_headers(member)
|
||||
mocker.patch("apps.blog.api.views.notify_user")
|
||||
assert client.post(f"/api/blog/posts/{post.slug}/like", **headers).json()["liked"] is True
|
||||
assert Like.objects.filter(post=post, user=member).exists()
|
||||
assert client.post(f"/api/blog/posts/{post.slug}/save", **headers).json()["saved"] is True
|
||||
assert SavedPost.objects.filter(post=post, user=member).exists()
|
||||
response = client.post(f"/api/blog/posts/{post.slug}/comments", data={"content": "A useful comment"},
|
||||
content_type="application/json", **headers)
|
||||
assert response.status_code == 201, response.content
|
||||
comment = Comment.objects.get(pk=response.json()["id"])
|
||||
assert comment.content == "A useful comment"
|
||||
assert client.get(f"/api/blog/posts/{post.slug}/interaction", **headers).json()["comments_count"] == 1
|
||||
activity = client.get("/api/blog/me/activity", **headers).json()
|
||||
assert activity["liked_posts"][0]["id"] == post.id
|
||||
assert activity["saved_posts"][0]["id"] == post.id
|
||||
assert activity["comments"][0]["id"] == comment.id
|
||||
assert client.put(f"/api/blog/comments/{comment.id}", data={"content": "Edited"},
|
||||
content_type="application/json", **headers).status_code == 200
|
||||
comment.refresh_from_db()
|
||||
assert comment.content == "Edited"
|
||||
assert client.post(f"/api/blog/posts/{post.slug}/like", **headers).json()["liked"] is False
|
||||
assert not Like.objects.filter(post=post, user=member).exists()
|
||||
|
||||
|
||||
def test_blog_editor_review_and_moderation(client, blog_content, user_factory, auth_headers, mocker):
|
||||
author, _, _, _, _, _, _ = blog_content
|
||||
editor = user_factory(is_staff=True)
|
||||
member = user_factory()
|
||||
editor_headers = auth_headers(editor)
|
||||
member_headers = auth_headers(member)
|
||||
mocker.patch("apps.blog.api.views.notify_user")
|
||||
assert client.get("/api/blog/admin/posts", **member_headers).status_code == 403
|
||||
created = client.post("/api/blog/admin/posts", data={"title": "Reviewed story", "content": "Body"},
|
||||
content_type="application/json", **editor_headers)
|
||||
assert created.status_code == 201, created.content
|
||||
post = Post.objects.get(pk=created.json()["id"])
|
||||
assert post.author == editor and post.status == Post.StatusChoices.DRAFT
|
||||
submitted = client.post(f"/api/blog/admin/posts/{post.id}/submit", **editor_headers)
|
||||
assert submitted.status_code == 200
|
||||
post.refresh_from_db()
|
||||
assert post.status == Post.StatusChoices.SUBMITTED
|
||||
invalid = client.post(f"/api/blog/admin/posts/{post.id}/review", data={"action": "nope"},
|
||||
content_type="application/json", **editor_headers)
|
||||
assert invalid.status_code == 400
|
||||
reviewed = client.post(f"/api/blog/admin/posts/{post.id}/review", data={"action": "publish"},
|
||||
content_type="application/json", **editor_headers)
|
||||
assert reviewed.status_code == 200, reviewed.content
|
||||
post.refresh_from_db()
|
||||
assert post.status == Post.StatusChoices.PUBLISHED and post.published_by == editor
|
||||
assert client.get(f"/api/blog/posts/{post.slug}").status_code == 200
|
||||
response = client.post(f"/api/blog/posts/{post.slug}/comments", data={"content": "Review me"},
|
||||
content_type="application/json", **member_headers)
|
||||
comment = Comment.objects.get(pk=response.json()["id"])
|
||||
assert client.post(f"/api/blog/comments/{comment.id}/hide", data={"note": "spam"},
|
||||
content_type="application/json", **member_headers).status_code == 403
|
||||
assert client.post(f"/api/blog/comments/{comment.id}/hide", data={"note": "spam"},
|
||||
content_type="application/json", **editor_headers).status_code == 200
|
||||
comment.refresh_from_db()
|
||||
assert comment.is_hidden
|
||||
assert client.get(f"/api/blog/posts/{post.slug}/comments").json() == []
|
||||
assert client.post(f"/api/blog/comments/{comment.id}/unhide", **editor_headers).status_code == 200
|
||||
comment.refresh_from_db()
|
||||
assert not comment.is_hidden
|
||||
assert client.post(f"/api/blog/comments/{comment.id}/delete", data={"note": "remove"},
|
||||
content_type="application/json", **editor_headers).status_code == 200
|
||||
assert Comment.deleted_objects.filter(pk=comment.pk).exists()
|
||||
assert client.post(f"/api/blog/deleted/comments/{comment.id}/restore", **editor_headers).status_code == 200
|
||||
assert Comment.objects.filter(pk=comment.pk).exists()
|
||||
|
||||
|
||||
def test_blog_taxonomy_admin_crud_and_restore(client, user_factory, auth_headers):
|
||||
admin = user_factory(is_superuser=True, is_staff=True)
|
||||
member = user_factory()
|
||||
admin_headers = auth_headers(admin)
|
||||
assert client.get("/api/blog/admin/categories", **auth_headers(member)).status_code == 403
|
||||
root = client.post("/api/blog/admin/categories", data={"name": "Research"},
|
||||
content_type="application/json", **admin_headers)
|
||||
assert root.status_code == 201, root.content
|
||||
category_id = root.json()["id"]
|
||||
assert Category.objects.filter(pk=category_id).exists()
|
||||
child = client.post("/api/blog/admin/categories", data={"name": "Reports", "parent_id": category_id},
|
||||
content_type="application/json", **admin_headers)
|
||||
assert child.status_code == 201, child.content
|
||||
invalid = client.put(f"/api/blog/admin/categories/{category_id}",
|
||||
data={"name": "Research", "parent_id": child.json()["id"]},
|
||||
content_type="application/json", **admin_headers)
|
||||
assert invalid.status_code == 400
|
||||
assert Category.objects.get(pk=category_id).parent_id is None
|
||||
tag = client.post("/api/blog/admin/tags", data={"name": "Python"},
|
||||
content_type="application/json", **admin_headers)
|
||||
assert tag.status_code == 201, tag.content
|
||||
tag_id = tag.json()["id"]
|
||||
assert client.put(f"/api/blog/admin/tags/{tag_id}", data={"name": "Django"},
|
||||
content_type="application/json", **admin_headers).status_code == 200
|
||||
assert Tag.objects.get(pk=tag_id).name == "Django"
|
||||
assert client.delete(f"/api/blog/admin/tags/{tag_id}", **admin_headers).status_code == 200
|
||||
assert Tag.deleted_objects.filter(pk=tag_id).exists()
|
||||
assert client.post(f"/api/blog/deleted/tags/{tag_id}/restore", **admin_headers).status_code == 200
|
||||
assert Tag.objects.filter(pk=tag_id).exists()
|
||||
|
||||
|
||||
def test_blog_post_admin_listing_update_assets_and_soft_delete(client, blog_content, user_factory,
|
||||
auth_headers, tmp_path, settings):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
author, _, _, _, post, _, draft = blog_content
|
||||
admin = user_factory(is_superuser=True, is_staff=True)
|
||||
member = user_factory()
|
||||
headers = auth_headers(admin)
|
||||
assert client.get("/api/blog/admin/posts", **headers).status_code == 200
|
||||
assert client.get(f"/api/blog/admin/posts/{post.pk}", **auth_headers(member)).status_code == 403
|
||||
assert client.get(f"/api/blog/admin/posts/{post.pk}", **headers).json()["id"] == post.pk
|
||||
updated = client.put(f"/api/blog/admin/posts/{draft.pk}", data='{"title":"New title","content":"Changed"}',
|
||||
content_type="application/json", **headers)
|
||||
assert updated.status_code == 200, updated.content
|
||||
draft.refresh_from_db()
|
||||
assert draft.title == "New title" and draft.content == "Changed"
|
||||
assert client.get(f"/api/blog/admin/posts/{post.pk}/assets", **headers).json() == []
|
||||
invalid = client.post(f"/api/blog/admin/posts/{post.pk}/assets",
|
||||
{"file": SimpleUploadedFile("unsafe.exe", b"data", content_type="application/octet-stream")},
|
||||
**headers)
|
||||
assert invalid.status_code == 400
|
||||
asset = client.post(f"/api/blog/admin/posts/{post.pk}/assets",
|
||||
{"file": SimpleUploadedFile("notes.txt", b"notes", content_type="text/plain"), "title": "Notes"},
|
||||
**headers)
|
||||
assert asset.status_code == 201, asset.content
|
||||
asset_id = asset.json()["id"]
|
||||
assert PostAsset.objects.filter(pk=asset_id, post=post).exists()
|
||||
assert len(client.get(f"/api/blog/admin/posts/{post.pk}/assets", **headers).json()) == 1
|
||||
assert client.delete(f"/api/blog/admin/posts/{post.pk}/assets/{asset_id}", **headers).status_code == 200
|
||||
assert not PostAsset.objects.filter(pk=asset_id).exists()
|
||||
assert client.delete(f"/api/blog/posts/{post.slug}", **auth_headers(author)).status_code == 403
|
||||
assert client.delete(f"/api/blog/posts/{post.slug}", **headers).status_code == 200
|
||||
assert Post.deleted_objects.filter(pk=post.pk).exists()
|
||||
assert client.get("/api/blog/deleted/posts", **headers).json()[0]["id"] == post.pk
|
||||
assert client.post(f"/api/blog/deleted/posts/{post.pk}/restore", **headers).status_code == 200
|
||||
assert Post.objects.filter(pk=post.pk).exists()
|
||||
|
||||
|
||||
def test_blog_comment_reply_and_edit_authorization(client, blog_content, user_factory, auth_headers, mocker):
|
||||
_, _, _, _, post, _, _ = blog_content
|
||||
first_user = user_factory()
|
||||
second_user = user_factory()
|
||||
notify = mocker.patch("apps.blog.api.views.notify_user")
|
||||
root = client.post(f"/api/blog/posts/{post.slug}/comments", data='{"content":"Root"}',
|
||||
content_type="application/json", **auth_headers(first_user))
|
||||
assert root.status_code == 201, root.content
|
||||
root_id = root.json()["id"]
|
||||
reply = client.post(f"/api/blog/posts/{post.slug}/comments",
|
||||
data=f'{{"content":"Reply","parent_id":{root_id}}}',
|
||||
content_type="application/json", **auth_headers(second_user))
|
||||
assert reply.status_code == 201, reply.content
|
||||
assert Comment.objects.get(pk=reply.json()["id"]).parent_id == root_id
|
||||
assert notify.call_count >= 2
|
||||
assert client.put(f"/api/blog/comments/{root_id}", data='{"content":"Changed"}',
|
||||
content_type="application/json", **auth_headers(second_user)).status_code == 403
|
||||
comments = client.get(f"/api/blog/posts/{post.slug}/comments").json()
|
||||
assert comments[0]["replies"][0]["id"] == reply.json()["id"]
|
||||
|
||||
|
||||
def test_blog_taxonomy_deletion_and_validation(client, auth_headers, user_factory):
|
||||
admin = user_factory(is_superuser=True, is_staff=True)
|
||||
headers = auth_headers(admin)
|
||||
category = Category.objects.create(name="Delete category")
|
||||
tag = Tag.objects.create(name="Delete tag")
|
||||
assert client.post("/api/blog/admin/categories", data='{"name":" "}',
|
||||
content_type="application/json", **headers).status_code == 400
|
||||
assert client.post("/api/blog/admin/tags", data='{"name":" "}',
|
||||
content_type="application/json", **headers).status_code == 400
|
||||
assert client.delete(f"/api/blog/admin/categories/{category.pk}", **headers).status_code == 200
|
||||
assert client.get("/api/blog/deleted/categories", **headers).json()[0]["id"] == category.pk
|
||||
assert client.post(f"/api/blog/deleted/categories/{category.pk}/restore", **headers).status_code == 200
|
||||
assert Category.objects.filter(pk=category.pk).exists()
|
||||
assert client.post("/api/blog/deleted/categories/999999/restore", **headers).status_code == 400
|
||||
assert client.get(f"/api/blog/tags/{tag.slug}").json()["id"] == tag.pk
|
||||
|
||||
|
||||
def test_blog_featured_image_and_compatibility_routes(client, blog_content, user_factory,
|
||||
auth_headers, tmp_path, settings):
|
||||
settings.MEDIA_ROOT = tmp_path
|
||||
_, _, _, _, post, _, _ = blog_content
|
||||
admin = user_factory(is_staff=True, is_superuser=True)
|
||||
headers = auth_headers(admin)
|
||||
assert client.get("/api/blog/admin/writers", **headers).status_code == 200
|
||||
assert client.get("/api/blog/admin/writers", **auth_headers(user_factory())).status_code == 403
|
||||
invalid = client.post(f"/api/blog/admin/posts/{post.pk}/featured-image",
|
||||
{"file": SimpleUploadedFile("notes.txt", b"text", content_type="text/plain")}, **headers)
|
||||
assert invalid.status_code == 400
|
||||
uploaded = client.post(f"/api/blog/admin/posts/{post.pk}/featured-image",
|
||||
{"file": SimpleUploadedFile("cover.png", b"image", content_type="image/png")}, **headers)
|
||||
assert uploaded.status_code == 200, uploaded.content
|
||||
post.refresh_from_db()
|
||||
assert post.featured_image
|
||||
assert client.delete(f"/api/blog/admin/posts/{post.pk}/featured-image", **headers).status_code == 200
|
||||
post.refresh_from_db()
|
||||
assert not post.featured_image
|
||||
compatible = client.post("/api/blog/posts", data='{"title":"Compat","content":"Body"}',
|
||||
content_type="application/json", **headers)
|
||||
assert compatible.status_code == 201, compatible.content
|
||||
compat_post = Post.objects.get(pk=compatible.json()["id"])
|
||||
update = client.put(f"/api/blog/posts/{compat_post.slug}", data='{"title":"Compat edited","content":"New body"}',
|
||||
content_type="application/json", **headers)
|
||||
assert update.status_code == 200, update.content
|
||||
compat_post.refresh_from_db()
|
||||
assert compat_post.title == "Compat edited"
|
||||
|
||||
|
||||
def test_blog_review_changes_and_taxonomy_parent_validation(client, blog_content, user_factory, auth_headers):
|
||||
_, _, _, _, _, _, draft = blog_content
|
||||
admin = user_factory(is_staff=True)
|
||||
headers = auth_headers(admin)
|
||||
changed = client.post(f"/api/blog/admin/posts/{draft.pk}/review",
|
||||
data='{"action":"request_changes","note":"Revise details"}',
|
||||
content_type="application/json", **headers)
|
||||
assert changed.status_code == 200, changed.content
|
||||
draft.refresh_from_db()
|
||||
assert draft.status == Post.StatusChoices.CHANGES_REQUESTED and draft.review_note == "Revise details"
|
||||
archived = client.post(f"/api/blog/admin/posts/{draft.pk}/review", data='{"action":"archive"}',
|
||||
content_type="application/json", **headers)
|
||||
assert archived.status_code == 200
|
||||
draft.refresh_from_db()
|
||||
assert draft.status == Post.StatusChoices.ARCHIVED
|
||||
root = Category.objects.create(name="Root for validation")
|
||||
child = Category.objects.create(name="Child for validation", parent=root)
|
||||
invalid = client.put(f"/api/blog/admin/categories/{child.pk}",
|
||||
data=f'{{"name":"Child for validation","parent_id":{child.pk}}}',
|
||||
content_type="application/json", **headers)
|
||||
assert invalid.status_code == 400
|
||||
child.refresh_from_db()
|
||||
assert child.parent_id == root.pk
|
||||
|
||||
|
||||
def test_blog_filters_accept_multiple_tag_values_and_page_results(client, blog_content):
|
||||
_, _, _, tag, first, second, _ = blog_content
|
||||
tagged = client.get("/api/blog/posts", {"tag": f"{tag.slug},unknown,{tag.slug}"})
|
||||
assert tagged.status_code == 200
|
||||
assert [row["id"] for row in tagged.json()] == [first.pk]
|
||||
first_page = client.get("/api/blog/posts", {"page": 1, "limit": 1}).json()
|
||||
second_page = client.get("/api/blog/posts", {"page": 2, "limit": 1}).json()
|
||||
assert len(first_page) == len(second_page) == 1
|
||||
assert {first_page[0]["id"], second_page[0]["id"]} == {first.pk, second.pk}
|
||||
assert client.get("/api/blog/posts", {"page": 3, "limit": 1}).json() == []
|
||||
assert [row["id"] for row in client.get(f"/api/blog/posts/{second.slug}/recommended").json()] == [first.pk]
|
||||
Reference in New Issue
Block a user