"""
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.
Public help-center tests: host resolution, published-only rendering with
escaping and JSON-LD, plan/enabled gating (404s), Ask AI guards, host
dispatch routing.
"""
import json
import re
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from app.api.help_center_public import public_app
from app.core.config import settings
from app.core.help_center_host import HelpCenterHostMiddleware, is_help_center_host
from app.database import get_db
from app.models.faq import FAQ, FAQStatus
from app.models.help_center import HelpCenterSettings
from app.repositories.faq import FAQRepository
from app.services.help_center_public import contrast_ink, resolve_help_center, slug_for_host
HOST = "test-org.help.replypier.site"
@pytest.fixture(autouse=True)
def _open_plan_gate():
"""Env-independent plan gating: local dev has the enterprise module (test
org has no subscription → the public site 404s), CI/OSS doesn't. These
tests target the public renderer, not the gate."""
with patch("app.services.help_center_public.help_center_allowed", return_value=True):
yield
@pytest.fixture
def client(db):
def override_db():
yield db
public_app.dependency_overrides[get_db] = override_db
yield TestClient(public_app)
public_app.dependency_overrides.clear()
@pytest.fixture
def help_center(db, test_organization):
row = HelpCenterSettings(
organization_id=test_organization.id,
slug="test-org",
enabled=True,
brand_color="#4338CA",
)
db.add(row)
db.commit()
db.refresh(row)
return row
def _publish_faq(db, org_id, question="How do I sign up?", answer="Use your email.",
category="Getting started", slug=None, url_path=None):
return FAQRepository(db).create(FAQ(
organization_id=org_id, question=question, answer=answer,
category=category, status=FAQStatus.PUBLISHED, slug=slug, url_path=url_path,
))
@pytest.fixture
def subdomain_mode(monkeypatch):
"""Advertise {slug}.{base} URLs (cloud config) for tests that assert them."""
monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "subdomain")
async def _not_found_app(scope, receive, send):
"""Main-app stand-in: anything not dispatched to public_app 404s as 'main app'."""
await send({"type": "http.response.start", "status": 404,
"headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": b"main app"})
@pytest.fixture
def path_client(db, monkeypatch):
"""Client through the real dispatch middleware in PATH mode, so /help/{slug}
requests exercise path dispatch (public_app is bypassed by the plain client)."""
monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "path")
def override_db():
yield db
public_app.dependency_overrides[get_db] = override_db
yield TestClient(HelpCenterHostMiddleware(_not_found_app, public_app))
public_app.dependency_overrides.clear()
# ---------- helpers ----------
def test_slug_for_host():
assert slug_for_host("acme.help.replypier.site") == "acme"
assert slug_for_host("a.b.help.replypier.site") is None
assert slug_for_host("help.replypier.site") is None
assert slug_for_host("help.acme.com") is None
def test_contrast_ink():
assert contrast_ink("#FFFFFF") == "#12131A"
assert contrast_ink("#0B0C10") == "#FFFFFF"
assert contrast_ink("not-a-color") == "#FFFFFF"
# ---------- rendering ----------
def test_page_renders_published_only_and_escapes(client, db, test_organization, help_center, subdomain_mode):
_publish_faq(db, test_organization.id)
FAQRepository(db).create(FAQ(
organization_id=test_organization.id, question="Secret draft?",
answer="Hidden.", category="Getting started", status=FAQStatus.DRAFT,
))
r = client.get("/", headers={"host": HOST})
assert r.status_code == 200
assert "How do I sign up?" in r.text
assert "Secret draft?" not in r.text
# The index shows a plain-text preview (Markdown stripped) that links to the
# article page — the answer's raw HTML never reaches the page.
assert "email" not in r.text
assert "Use your email" in r.text
# SEO artifacts.
assert 'application/ld+json' in r.text and "FAQPage" in r.text
assert f'' in r.text
assert r.headers["cache-control"] == "public, max-age=60"
def test_article_page_renders_sanitized_markdown(client, db, test_organization, help_center):
faq = _publish_faq(
db, test_organization.id,
answer="Click **Settings**.\n\n",
)
faq.slug = "how-do-i-sign-up"
db.commit()
r = client.get(f"/a/{faq.slug}", headers={"host": HOST})
assert r.status_code == 200
# Markdown rendered, but the injected ', response.text, re.DOTALL
)
assert blocks, "page emitted no JSON-LD"
return json.loads(blocks[0])
def _nodes_by_type(graph: dict) -> dict:
"""Graph nodes keyed by @type (multi-typed nodes land under each type)."""
nodes: dict = {}
for node in graph["@graph"]:
types = node["@type"]
for node_type in [types] if isinstance(types, str) else types:
nodes[node_type] = node
return nodes
def test_index_json_ld_graph_keeps_faqpage(client, db, test_organization, help_center, subdomain_mode):
"""The landing page stays a FAQPage (it really is a list of Q&A), wired into
an @graph with the Organization and WebSite nodes."""
_publish_faq(db, test_organization.id, answer="Click **Settings**.")
r = client.get("/", headers={"host": HOST})
assert r.status_code == 200
nodes = _nodes_by_type(_json_ld(r))
assert set(nodes) >= {"Organization", "WebSite", "CollectionPage", "FAQPage"}
page = nodes["FAQPage"]
# Same node carries both types, and it is wired to the WebSite by @id.
assert page["@id"] == nodes["CollectionPage"]["@id"]
assert page["isPartOf"]["@id"] == nodes["WebSite"]["@id"]
assert nodes["WebSite"]["publisher"]["@id"] == nodes["Organization"]["@id"]
question = page["mainEntity"][0]
assert question["@type"] == "Question"
assert question["name"] == "How do I sign up?"
# Answer text is plain text, with the Markdown stripped.
assert question["acceptedAnswer"]["text"] == "Click Settings ."
assert "QAPage" not in r.text
def test_article_json_ld_is_techarticle_with_breadcrumbs(
client, db, test_organization, help_center, subdomain_mode
):
"""A single help article is a TechArticle, not a FAQPage: Google retired FAQ
rich results, while BreadcrumbList still renders in search."""
faq = _publish_faq(db, test_organization.id, answer="Click **Settings**.")
faq.slug = "how-do-i-sign-up"
db.commit()
r = client.get(f"/a/{faq.slug}", headers={"host": HOST})
assert r.status_code == 200
nodes = _nodes_by_type(_json_ld(r))
assert set(nodes) >= {"Organization", "WebSite", "WebPage", "BreadcrumbList", "TechArticle"}
assert "FAQPage" not in nodes and "QAPage" not in r.text
article, page, crumbs = nodes["TechArticle"], nodes["WebPage"], nodes["BreadcrumbList"]
assert article["headline"] == "How do I sign up?"
assert article["articleSection"] == "Getting started"
# Every node is wired by @id, not repeated inline.
assert article["mainEntityOfPage"]["@id"] == page["@id"]
assert article["publisher"]["@id"] == nodes["Organization"]["@id"]
assert page["breadcrumb"]["@id"] == crumbs["@id"]
# datePublished comes from the row; dateModified falls back to it when the
# article has never been edited (so it can't predate publication).
assert article["dateModified"] == article["datePublished"]
items = crumbs["itemListElement"]
assert [c["position"] for c in items] == [1, 2, 3]
assert [c["name"] for c in items] == ["Home", "Getting started", "How do I sign up?"]
assert items[-1]["item"] == f"https://{HOST}/a/how-do-i-sign-up"
def test_article_renders_visible_breadcrumb_matching_markup(
client, db, test_organization, help_center
):
"""Google requires the BreadcrumbList to match what the page shows, so the
trail must actually be rendered — the current page as text, not a link."""
faq = _publish_faq(db, test_organization.id)
faq.slug = "how-do-i-sign-up"
db.commit()
r = client.get(f"/a/{faq.slug}", headers={"host": HOST})
assert '