test: 补充权限、消息与会话服务回归用例

会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
WangShen007 committed 2026-10-08 22:35:00 +08:00
1 parent 8e3a4d95da
commit 164f5f9615
97 files changed
+37281

No files matched your search

+429
View File
@@ -0,0 +1,429 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Help-center admin API tests: settings get-or-create + slug, FAQ CRUD and
publish flow, generation/import job enqueueing (409/400 guards, SSRF),
and plan gating in simulated cloud mode.
"""
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
import app.main # noqa: F401 — ensures routers are registered on the FastAPI app
from app.core.application import app
from app.core.auth import get_current_user, require_permissions
from app.database import get_db
from app.models.faq import FAQ, FAQStatus
from app.repositories.faq import FAQRepository
BASE = "/api/v1/help-center"
@pytest.fixture(autouse=True)
def _open_plan_gate(request):
"""Env-independent plan gating: local dev has the enterprise module (test
org has no subscription → everything 403s), CI/OSS doesn't. These tests
target the help-center endpoints, not the gate — the plan-gating tests
below opt out via the `real_plan_gate` marker to exercise it for real."""
if request.node.get_closest_marker("real_plan_gate"):
yield
return
# Patch the names bound inside help_center_access (import-time binding —
# patching feature_gate itself would not reach them).
with patch("app.services.help_center_access.feature_allowed", return_value=True), \
patch("app.services.help_center_access.check_feature_access", return_value=None):
yield
@pytest.fixture
def client(db, test_user):
# The shared test_role lacks manage_knowledge, which every help-center
# endpoint requires.
from app.models.permission import Permission
perm = Permission(name="manage_knowledge")
db.add(perm)
test_user.role.permissions.append(perm)
db.commit()
async def override_user():
return test_user
def override_db():
yield db
# NOTE: overriding require_permissions itself would be a no-op (FastAPI
# keys overrides on the closure it returns) — the real permission check
# runs against test_user's role, which now has manage_knowledge.
app.dependency_overrides[get_current_user] = override_user
app.dependency_overrides[get_db] = override_db
yield TestClient(app)
app.dependency_overrides.clear()
def _create_faq(db, org_id, **kwargs):
defaults = dict(question="How do I sign up?", answer="With your email.", category="Getting started")
defaults.update(kwargs)
return FAQRepository(db).create(FAQ(organization_id=org_id, **defaults))
# ---------- settings ----------
def test_settings_get_or_create_assigns_slug_and_defaults(client, test_agent):
r = client.get(f"{BASE}/settings")
assert r.status_code == 200
body = r.json()
assert body["slug"] == "test-organization"
# Default (path) mode advertises {BACKEND_URL}/help/{slug}.
assert body["live_url"].endswith("/help/test-organization")
assert body["plan_allowed"] is True # OSS mode: never locked
assert body["auto_generate"] is True
assert body["ai_search_enabled"] is True
assert [a["name"] for a in body["agents"]] == ["Test Agent"]
# Second call returns the same row (no duplicate creation).
assert client.get(f"{BASE}/settings").json()["slug"] == "test-organization"
def test_settings_update_branding(client):
client.get(f"{BASE}/settings")
r = client.put(f"{BASE}/settings", json={
"brand_color": "#0E8C8C",
"header_links": [{"label": "Product", "url": "example.com"}],
"cta_text": "Sign in",
"cta_url": "app.example.com",
"enabled": True,
})
assert r.status_code == 200
body = r.json()
assert body["brand_color"] == "#0E8C8C"
assert body["header_links"] == [{"label": "Product", "url": "https://example.com"}]
assert body["cta_url"] == "https://app.example.com"
assert body["enabled"] is True
def test_settings_update_rejects_bad_color_and_cross_org_agent(client):
assert client.put(f"{BASE}/settings", json={"brand_color": "red"}).status_code == 422
assert client.put(f"{BASE}/settings", json={"agent_id": str(uuid4())}).status_code == 404
# ---------- FAQ CRUD ----------
def test_faq_crud_flow(client, db, test_organization):
created = client.post(f"{BASE}/faqs", json={
"question": "What does it cost?", "answer": "From $19.", "category": "Billing",
})
assert created.status_code == 201
faq_id = created.json()["id"]
assert created.json()["status"] == "draft"
assert created.json()["source_label"] == "Added manually"
# Partial update must not reset omitted fields (category keeps Billing).
updated = client.put(f"{BASE}/faqs/{faq_id}", json={"status": "published"})
assert updated.status_code == 200
assert updated.json()["status"] == "published"
assert updated.json()["category"] == "Billing"
listing = client.get(f"{BASE}/faqs", params={"status": "published"})
assert listing.status_code == 200
assert listing.json()["pagination"]["total"] == 1
assert client.get(f"{BASE}/faqs/categories").json() == ["Billing"]
assert client.delete(f"{BASE}/faqs/{faq_id}").status_code == 204
assert client.get(f"{BASE}/faqs").json()["pagination"]["total"] == 0
def test_faq_seo_fields_round_trip(client):
"""Slug and meta overrides are settable at create and edit time."""
created = client.post(f"{BASE}/faqs", json={
"question": "What does it cost?", "answer": "From $19.",
"slug": "Pricing & Plans!", "meta_title": "Pricing",
"meta_description": "What ReplyPier costs.",
})
assert created.status_code == 201
# A hand-typed slug is normalized the same way a generated one is.
assert created.json()["slug"] == "pricing-plans"
assert created.json()["meta_title"] == "Pricing"
faq_id = created.json()["id"]
updated = client.put(f"{BASE}/faqs/{faq_id}", json={"slug": "our-pricing"})
assert updated.json()["slug"] == "our-pricing"
# Untouched overrides survive a partial update.
assert updated.json()["meta_description"] == "What ReplyPier costs."
def test_faq_slug_is_deduped_but_stable_for_its_own_row(client):
"""A taken slug gets a numeric suffix; re-saving a row's own slug is a
no-op rather than a self-collision that renames the article."""
first = client.post(f"{BASE}/faqs", json={
"question": "A", "answer": "a", "slug": "pricing",
}).json()
second = client.post(f"{BASE}/faqs", json={
"question": "B", "answer": "b", "slug": "pricing",
}).json()
assert first["slug"] == "pricing"
assert second["slug"] == "pricing-2"
resaved = client.put(f"{BASE}/faqs/{first['id']}", json={"slug": "pricing"})
assert resaved.json()["slug"] == "pricing"
def test_blank_seo_values_clear_overrides_but_keep_the_slug(client):
"""Emptying a field in the UI restores the derived default. The slug has no
derived-at-render fallback, so a blank one keeps the assigned value instead
of leaving the article unreachable."""
faq = client.post(f"{BASE}/faqs", json={
"question": "What does it cost?", "answer": "From $19.", "meta_title": "Pricing",
}).json()
cleared = client.put(f"{BASE}/faqs/{faq['id']}", json={"meta_title": "", "slug": ""})
assert cleared.json()["meta_title"] is None
assert cleared.json()["slug"] == faq["slug"]
def test_faq_seo_fields_reject_oversized_values(client):
"""Lengths are bounded at the API so a value can never exceed its column."""
over_title = client.post(f"{BASE}/faqs", json={
"question": "Q", "answer": "a", "meta_title": "x" * 121,
})
over_description = client.post(f"{BASE}/faqs", json={
"question": "Q", "answer": "a", "meta_description": "x" * 301,
})
assert over_title.status_code == 422
assert over_description.status_code == 422
def test_faq_cross_org_is_404(client, db):
from app.models.organization import Organization
other_org = Organization(name="Other", domain="other.com", timezone="UTC")
db.add(other_org)
db.commit()
foreign = _create_faq(db, other_org.id)
assert client.put(f"{BASE}/faqs/{foreign.id}", json={"status": "published"}).status_code == 404
assert client.delete(f"{BASE}/faqs/{foreign.id}").status_code == 404
def test_bulk_status_publish(client, db, test_organization):
ids = [str(_create_faq(db, test_organization.id, question=f"Q{i}?").id) for i in range(3)]
r = client.post(f"{BASE}/faqs/bulk-status", json={"faq_ids": ids, "status": "published"})
assert r.status_code == 200
assert r.json()["updated"] == 3
def test_bulk_delete_is_org_scoped(client, db, test_organization):
from app.models.organization import Organization
mine = [str(_create_faq(db, test_organization.id, question=f"D{i}?").id) for i in range(2)]
other_org = Organization(name="Other2", domain="other2.com", timezone="UTC")
db.add(other_org)
db.commit()
foreign = _create_faq(db, other_org.id)
r = client.post(f"{BASE}/faqs/bulk-delete", json={"faq_ids": mine + [str(foreign.id)]})
assert r.status_code == 200
assert r.json()["deleted"] == 2 # foreign id silently ignored
assert client.get(f"{BASE}/faqs").json()["pagination"]["total"] == 0
db.expire_all()
assert db.get(FAQ, foreign.id) is not None # other org untouched
# ---------- generation / import ----------
def test_generate_requires_ai_config(client):
assert client.post(f"{BASE}/generate").status_code == 400
def _create_knowledge(db, org_id, source="docs.example.com"):
from app.models.knowledge import Knowledge, SourceType
knowledge = Knowledge(
source=source, source_type=SourceType.WEBSITE,
schema="ai", table_name="d_test", organization_id=org_id,
)
db.add(knowledge)
db.commit()
return knowledge
def test_generate_requires_knowledge_sources(client, test_ai_config):
assert client.post(f"{BASE}/generate").status_code == 400
def test_generate_enqueues_and_409s_on_duplicate(client, db, test_organization, test_ai_config):
_create_knowledge(db, test_organization.id)
first = client.post(f"{BASE}/generate")
assert first.status_code == 202
assert first.json()["status"] == "pending"
assert client.post(f"{BASE}/generate").status_code == 409
# Polling returns the active job.
active = client.get(f"{BASE}/jobs")
assert active.json()["id"] == first.json()["id"]
def test_generate_409s_when_all_sources_generated(client, db, test_organization, test_ai_config):
knowledge = _create_knowledge(db, test_organization.id)
_create_faq(db, test_organization.id, knowledge_id=knowledge.id)
r = client.post(f"{BASE}/generate")
assert r.status_code == 409
assert "already have FAQs" in r.json()["detail"]
def test_generate_estimate_reports_new_sources(client, db, test_organization, test_ai_config):
generated = _create_knowledge(db, test_organization.id, "done.example.com")
_create_faq(db, test_organization.id, knowledge_id=generated.id)
_create_knowledge(db, test_organization.id, "new.example.com")
r = client.get(f"{BASE}/generate/estimate")
assert r.status_code == 200
body = r.json()
assert body["total_sources"] == 2
assert body["new_sources"] == 1
assert body["estimated_calls"] >= 1
# Per-source list for the picker: both sources, with has_faqs per source.
by_name = {s["name"]: s for s in body["sources"]}
assert set(by_name) == {"done.example.com", "new.example.com"}
assert by_name["done.example.com"]["has_faqs"] is True
assert by_name["new.example.com"]["has_faqs"] is False
def test_import_rejects_internal_hosts(client, test_ai_config):
with patch("app.api.help_center.generation.resolves_to_blocked_host", return_value=True):
assert client.post(f"{BASE}/import", json={"url": "https://internal.host/faq"}).status_code == 400
def test_import_enqueues_with_source_url(client, test_ai_config):
with patch("app.api.help_center.generation.resolves_to_blocked_host", return_value=False):
r = client.post(f"{BASE}/import", json={"url": "support.example.com/faq"})
assert r.status_code == 202
assert r.json()["source_url"] == "https://support.example.com/faq"
assert r.json()["job_type"] == "import_url"
def test_import_articles_carries_the_preserve_urls_opt_in(client, db, test_ai_config):
"""The migration toggle has to reach the worker, or imported articles quietly
lose the URLs the org already ranks for."""
from app.models.faq_generation_job import FAQGenerationJob
with patch("app.api.help_center.generation.resolves_to_blocked_host", return_value=False):
r = client.post(
f"{BASE}/import",
json={"url": "support.example.com/help", "mode": "articles", "preserve_urls": True},
)
assert r.status_code == 202
assert r.json()["job_type"] == "import_articles"
job = db.query(FAQGenerationJob).filter(FAQGenerationJob.id == r.json()["id"]).one()
assert job.preserve_source_urls is True
def test_import_defaults_to_not_preserving_urls(client, db, test_ai_config):
from app.models.faq_generation_job import FAQGenerationJob
with patch("app.api.help_center.generation.resolves_to_blocked_host", return_value=False):
r = client.post(f"{BASE}/import", json={"url": "support.example.com/help", "mode": "articles"})
job = db.query(FAQGenerationJob).filter(FAQGenerationJob.id == r.json()["id"]).one()
assert job.preserve_source_urls is False
def test_job_by_id_cross_org_404(client):
assert client.get(f"{BASE}/jobs/999999").status_code == 404
# ---------- plan gating (simulated cloud) ----------
def _cloud_gate(feature_available: bool):
"""Simulate the enterprise module being present with a plan whose
help_center flag is `feature_available`."""
subscription = MagicMock(plan_id=uuid4())
plan_repo = MagicMock()
plan_repo.check_feature_availability.return_value = feature_available
return (
patch("app.services.feature_gate.HAS_ENTERPRISE", True),
patch("app.services.feature_gate.require_accessible_subscription",
return_value=subscription, create=True),
patch("app.services.feature_gate.PlanRepository",
return_value=plan_repo, create=True),
)
@pytest.mark.real_plan_gate
def test_cloud_free_plan_gets_403_on_writes(client, test_ai_config):
p1, p2, p3 = _cloud_gate(feature_available=False)
with p1, p2, p3:
assert client.post(f"{BASE}/faqs", json={"question": "Q?", "answer": "A."}).status_code == 403
assert client.post(f"{BASE}/generate").status_code == 403
# Ungated read still works and reports the lock.
settings_response = client.get(f"{BASE}/settings")
assert settings_response.status_code == 200
assert settings_response.json()["plan_allowed"] is False
@pytest.mark.real_plan_gate
def test_cloud_pro_plan_allowed(client, test_ai_config):
p1, p2, p3 = _cloud_gate(feature_available=True)
with p1, p2, p3:
assert client.post(f"{BASE}/faqs", json={"question": "Q?", "answer": "A."}).status_code == 201
assert client.get(f"{BASE}/settings").json()["plan_allowed"] is True
# ---------- per-article preserved URL path ----------
def test_create_faq_with_a_preserved_url_path(client, db, test_organization):
r = client.post(f"{BASE}/faqs", json={
"question": "How do I reset my password?", "answer": "Use the link.",
"url_path": "https://help.acme.com/hc/en-us/articles/360012-reset?utm=x",
})
assert r.status_code == 201
assert r.json()["url_path"] == "/hc/en-us/articles/360012-reset"
def test_update_faq_sets_then_clears_the_url_path(client, db, test_organization):
faq = _create_faq(db, test_organization.id)
r = client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": "/hc/articles/1"})
assert r.status_code == 200
assert r.json()["url_path"] == "/hc/articles/1"
# Blank CLEARS it (unlike the slug, where blank is a no-op) — back to /a/{slug}.
r = client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": ""})
assert r.status_code == 200
assert r.json()["url_path"] is None
assert r.json()["slug"]
def test_update_faq_keeps_its_own_url_path_on_resave(client, db, test_organization):
faq = _create_faq(db, test_organization.id, url_path="/hc/articles/1")
r = client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": "/hc/articles/1"})
assert r.status_code == 200
assert r.json()["url_path"] == "/hc/articles/1"
def test_update_faq_rejects_a_url_path_another_article_owns(client, db, test_organization):
_create_faq(db, test_organization.id, question="Taken", url_path="/hc/articles/1")
faq = _create_faq(db, test_organization.id, question="Other")
r = client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": "/hc/articles/1"})
assert r.status_code == 409
@pytest.mark.parametrize("bad", ["/a/x", "/ask", "/api/v1/uploads/x", "/sitemap.xml", "/"])
def test_update_faq_rejects_url_paths_that_would_shadow_a_route(
client, db, test_organization, bad
):
faq = _create_faq(db, test_organization.id)
assert client.put(f"{BASE}/faqs/{faq.id}", json={"url_path": bad}).status_code == 400