补充测试覆盖率 + 新增部署引导命令 bootstrap_deployment

- 新增 apps/blog、apps/solutions、apps/home、apps/api 测试文件,覆盖标签/排序/
  草稿过滤、HomePage.subpage_types 回归测试、page_preview 预览接口
- apps/core/tests.py 新增健康检查端点、发布 webhook 信号、bootstrap_deployment
  命令的测试用例,pytest 用例数从 40 增至 65,全部通过
- 修复 apps/api/urls.py 中 PagePreviewAPIViewSet.get_object() 未捕获
  DoesNotExist 导致 500 而非文档承诺 404 的 bug
- 新增 apps/core/management/commands/bootstrap_deployment.py:部署时按环境变量
  幂等创建首个超级管理员账号并联动初始化 RBAC 权限组,.env.example 补充对应
  环境变量说明
- 新增 OpenSearch + 中文分词本地 PoC 脚手架(docker-compose.opensearch.yml +
  docker/opensearch/Dockerfile),调研结论详见设计文档 §2.10
- 更新设计文档 §2.16 Phase 1 进度清单
This commit is contained in:
2026-08-11 14:47:10 +08:00
parent f37571f395
commit 147107501b
11 changed files with 537 additions and 9 deletions
+170 -1
View File
@@ -269,7 +269,6 @@ def test_setup_rbac_groups_page_permissions_match_role_matrix():
# 编辑可以创建/编辑但不能发布
editor_perms = page_codenames("编辑")
assert editor_perms == {"add_page", "change_page", "view_page"}
assert "publish_page" not in editor_perms
# 审核员可以编辑并发布(用于审批发布场景)
assert page_codenames("审核员") == {"change_page", "publish_page", "view_page"}
# 作者只有 add + view:只能创建页面,仅能编辑自己拥有的页面(Wagtail 内置 ownership 策略)
@@ -279,6 +278,105 @@ def test_setup_rbac_groups_page_permissions_match_role_matrix():
assert page_codenames("查看者") == {"view_page"}
# ---------------------------------------------------------------------------
# 健康检查端点(apps/core/views.py,供 K8s liveness/readiness 探针使用)
# ---------------------------------------------------------------------------
def test_healthz_returns_ok(client):
response = client.get("/healthz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_readyz_returns_ok_when_database_available(client):
response = client.get("/readyz")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_readyz_returns_503_when_database_unavailable(client, monkeypatch):
from django.db import connection
def broken_cursor(*args, **kwargs):
raise Exception("simulated db outage")
monkeypatch.setattr(connection, "cursor", broken_cursor)
response = client.get("/readyz")
assert response.status_code == 503
assert response.json()["status"] == "error"
# ---------------------------------------------------------------------------
# 发布后前端 revalidate webhookapps/core/signals.py
# ---------------------------------------------------------------------------
def test_notify_frontend_revalidate_skips_when_url_not_configured(
settings, monkeypatch, home_page
):
from wagtail.signals import page_published
settings.FRONTEND_REVALIDATE_URL = ""
calls = []
monkeypatch.setattr(
"apps.core.signals.requests.post",
lambda *args, **kwargs: calls.append((args, kwargs)),
)
page_published.send(sender=type(home_page), instance=home_page)
assert calls == []
def test_notify_frontend_revalidate_posts_webhook_when_configured(
settings, monkeypatch, home_page
):
from wagtail.signals import page_published
settings.FRONTEND_REVALIDATE_URL = "https://frontend.example.com/api/revalidate"
settings.REVALIDATE_SECRET = "test-secret"
calls = []
def fake_post(url, json=None, headers=None, timeout=None):
calls.append({"url": url, "json": json, "headers": headers})
class FakeResponse:
status_code = 200
return FakeResponse()
monkeypatch.setattr("apps.core.signals.requests.post", fake_post)
page_published.send(sender=type(home_page), instance=home_page)
assert len(calls) == 1
assert calls[0]["url"] == "https://frontend.example.com/api/revalidate"
assert calls[0]["headers"] == {"Authorization": "Bearer test-secret"}
assert f"page:{home_page.id}" in calls[0]["json"]["tags"]
def test_notify_frontend_revalidate_swallows_request_exception(
settings, monkeypatch, home_page
):
import requests as requests_module
from wagtail.signals import page_published
settings.FRONTEND_REVALIDATE_URL = "https://frontend.example.com/api/revalidate"
def raise_request_exception(*args, **kwargs):
raise requests_module.RequestException("network error")
monkeypatch.setattr("apps.core.signals.requests.post", raise_request_exception)
# 不应向外抛出异常——webhook 失败不能影响页面发布流程。
page_published.send(sender=type(home_page), instance=home_page)
def test_setup_rbac_groups_author_can_only_edit_own_pages(home_page):
"""验证“Author 仅能编辑自己的页面”这一矩阵要求,由 Wagtail 内置的
OwnershipPermissionPolicy(仅 add 权限)自然实现,无需自定义 hook。"""
@@ -363,3 +461,74 @@ def test_setup_rbac_groups_is_idempotent():
group=group, page=root_page, permission__codename="add_page"
).count() == 1
assert group.permissions.filter(codename="access_admin").count() == 1
# ---------------------------------------------------------------------------
# 部署引导(bootstrap_deployment 管理命令)
# ---------------------------------------------------------------------------
def test_bootstrap_deployment_creates_superuser_from_env_vars(monkeypatch):
from django.core.management import call_command
monkeypatch.setenv("DJANGO_SUPERUSER_USERNAME", "deploy-admin")
monkeypatch.setenv("DJANGO_SUPERUSER_EMAIL", "deploy-admin@example.com")
monkeypatch.setenv("DJANGO_SUPERUSER_PASSWORD", "s3cret-pass")
call_command("bootstrap_deployment")
User = get_user_model()
user = User.objects.get(username="deploy-admin")
assert user.is_superuser is True
assert user.is_staff is True
assert user.email == "deploy-admin@example.com"
assert user.check_password("s3cret-pass") is True
def test_bootstrap_deployment_skips_superuser_creation_when_env_vars_missing(monkeypatch):
from django.core.management import call_command
monkeypatch.delenv("DJANGO_SUPERUSER_USERNAME", raising=False)
monkeypatch.delenv("DJANGO_SUPERUSER_EMAIL", raising=False)
monkeypatch.delenv("DJANGO_SUPERUSER_PASSWORD", raising=False)
User = get_user_model()
count_before = User.objects.count()
call_command("bootstrap_deployment")
assert User.objects.count() == count_before
def test_bootstrap_deployment_does_not_overwrite_existing_superuser_password(monkeypatch):
from django.core.management import call_command
User = get_user_model()
User.objects.create_superuser(
username="deploy-admin", email="old@example.com", password="original-pass"
)
monkeypatch.setenv("DJANGO_SUPERUSER_USERNAME", "deploy-admin")
monkeypatch.setenv("DJANGO_SUPERUSER_EMAIL", "new@example.com")
monkeypatch.setenv("DJANGO_SUPERUSER_PASSWORD", "new-pass")
call_command("bootstrap_deployment")
user = User.objects.get(username="deploy-admin")
assert user.email == "old@example.com"
assert user.check_password("original-pass") is True
assert user.check_password("new-pass") is False
def test_bootstrap_deployment_also_initializes_rbac_groups(monkeypatch):
from django.contrib.auth.models import Group
from django.core.management import call_command
monkeypatch.delenv("DJANGO_SUPERUSER_USERNAME", raising=False)
monkeypatch.delenv("DJANGO_SUPERUSER_EMAIL", raising=False)
monkeypatch.delenv("DJANGO_SUPERUSER_PASSWORD", raising=False)
call_command("bootstrap_deployment")
expected_names = {"站点管理员", "编辑", "审核员", "作者", "查看者"}
assert expected_names <= set(Group.objects.values_list("name", flat=True))