test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
This commit is contained in:
1 parent
8e3a4d95da
commit
164f5f9615
97 files changed
+37281
No files matched your search
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
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 SEO helpers: absolute-vs-relative URL handling across the three
|
||||
serving modes, meta derivation/overrides, and the JSON-LD graph wiring.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.help_center_seo import (
|
||||
absolute_asset_url,
|
||||
article_description,
|
||||
article_href,
|
||||
article_json_ld,
|
||||
article_title,
|
||||
article_url,
|
||||
asset_origin,
|
||||
breadcrumbs,
|
||||
index_json_ld,
|
||||
site_url,
|
||||
)
|
||||
|
||||
|
||||
def _row(**kw):
|
||||
base = {
|
||||
"domain_verified": False,
|
||||
"custom_domain": None,
|
||||
"slug": "acme",
|
||||
"title": None,
|
||||
"description": None,
|
||||
"organization": SimpleNamespace(name="Acme"),
|
||||
}
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _faq(**kw):
|
||||
base = {
|
||||
"question": "How do I sign up?",
|
||||
"answer": "Click **Settings**.",
|
||||
"category": "Getting started",
|
||||
"slug": "how-do-i-sign-up",
|
||||
"url_path": None,
|
||||
"meta_title": None,
|
||||
"meta_description": None,
|
||||
"created_at": datetime(2026, 1, 2, tzinfo=timezone.utc),
|
||||
"updated_at": None,
|
||||
}
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def _graph(doc):
|
||||
"""Graph nodes keyed by @type (multi-typed nodes land under each type)."""
|
||||
nodes = {}
|
||||
for node in doc["@graph"]:
|
||||
types = node["@type"]
|
||||
for node_type in [types] if isinstance(types, str) else types:
|
||||
nodes[node_type] = node
|
||||
return nodes
|
||||
|
||||
|
||||
# ---------- URL space ----------
|
||||
|
||||
def test_asset_origin_drops_the_path_in_path_mode(monkeypatch):
|
||||
"""In path mode the site lives under /help/{slug} but uploads stay at the
|
||||
origin root, so assets must anchor to the origin, not the site prefix."""
|
||||
monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "path")
|
||||
monkeypatch.setattr(settings, "BACKEND_URL", "http://localhost:8000")
|
||||
site = site_url(_row())
|
||||
assert site == "http://localhost:8000/help/acme"
|
||||
assert asset_origin(site) == "http://localhost:8000"
|
||||
assert (
|
||||
absolute_asset_url("/api/v1/uploads/help_center/l.png", site)
|
||||
== "http://localhost:8000/api/v1/uploads/help_center/l.png"
|
||||
)
|
||||
|
||||
|
||||
def test_absolute_asset_url_passes_s3_urls_through():
|
||||
"""S3 URLs are already absolute — anchoring them would corrupt them."""
|
||||
stored = "https://bucket.s3.amazonaws.com/help_center/l.png"
|
||||
assert absolute_asset_url(stored, "https://help.acme.com") == stored
|
||||
assert absolute_asset_url(None, "https://help.acme.com") is None
|
||||
|
||||
|
||||
def test_site_url_has_no_trailing_slash(monkeypatch):
|
||||
"""Every absolute URL is built by concatenation, so a trailing slash here
|
||||
would produce '//a/slug' everywhere."""
|
||||
monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "path")
|
||||
monkeypatch.setattr(settings, "BACKEND_URL", "http://localhost:8000/")
|
||||
assert site_url(_row()) == "http://localhost:8000/help/acme"
|
||||
|
||||
|
||||
# ---------- meta ----------
|
||||
|
||||
def test_meta_derives_from_content_then_yields_to_overrides():
|
||||
row, faq = _row(), _faq()
|
||||
assert article_title(row, faq) == "How do I sign up? · Acme Help Center"
|
||||
assert article_description(faq) == "Click Settings ."
|
||||
|
||||
overridden = _faq(meta_title="Signing up", meta_description="Custom.")
|
||||
assert article_title(row, overridden) == "Signing up"
|
||||
assert article_description(overridden) == "Custom."
|
||||
|
||||
|
||||
def test_description_falls_back_to_the_question_for_prose_free_answers():
|
||||
assert article_description(_faq(answer="")) == "How do I sign up?"
|
||||
|
||||
|
||||
# ---------- breadcrumbs ----------
|
||||
|
||||
def test_breadcrumbs_carry_relative_hrefs_and_absolute_urls():
|
||||
"""The page links relatively (so the markup works on any origin) while
|
||||
BreadcrumbList advertises absolute URLs."""
|
||||
site = "https://help.acme.com"
|
||||
crumbs = breadcrumbs(_row(), _faq(), "/help/acme", site)
|
||||
assert [c["label"] for c in crumbs] == ["Home", "Getting started", "How do I sign up?"]
|
||||
assert crumbs[0]["href"] == "/help/acme/"
|
||||
assert crumbs[1]["href"] == "/help/acme/?topic=Getting%20started"
|
||||
assert crumbs[1]["url"] == f"{site}/?topic=Getting%20started"
|
||||
# The current page is rendered as text, so it has no link.
|
||||
assert crumbs[-1]["href"] is None
|
||||
assert crumbs[-1]["url"] == f"{site}/a/how-do-i-sign-up"
|
||||
|
||||
|
||||
# ---------- JSON-LD ----------
|
||||
|
||||
def test_article_graph_is_wired_by_id():
|
||||
site = "https://help.acme.com"
|
||||
faq = _faq()
|
||||
crumbs = breadcrumbs(_row(), faq, "", site)
|
||||
nodes = _graph(article_json_ld(_row(), faq, site, crumbs, None, None))
|
||||
|
||||
page_id = f"{site}/a/how-do-i-sign-up#webpage"
|
||||
assert nodes["WebPage"]["@id"] == page_id
|
||||
assert nodes["TechArticle"]["mainEntityOfPage"]["@id"] == page_id
|
||||
assert nodes["WebPage"]["breadcrumb"]["@id"] == nodes["BreadcrumbList"]["@id"]
|
||||
assert nodes["TechArticle"]["author"]["@id"] == nodes["Organization"]["@id"]
|
||||
assert "FAQPage" not in nodes
|
||||
|
||||
|
||||
def test_article_graph_omits_empty_optional_nodes():
|
||||
"""No logo and no image must not leave null-valued keys in the graph."""
|
||||
site = "https://help.acme.com"
|
||||
faq = _faq()
|
||||
nodes = _graph(
|
||||
article_json_ld(_row(), faq, site, breadcrumbs(_row(), faq, "", site), None, None)
|
||||
)
|
||||
assert "logo" not in nodes["Organization"]
|
||||
assert "image" not in nodes["TechArticle"]
|
||||
|
||||
|
||||
def test_date_modified_never_predates_publication():
|
||||
"""updated_at is NULL until the first edit, so it falls back to created_at
|
||||
rather than being omitted or set earlier than datePublished."""
|
||||
site = "https://help.acme.com"
|
||||
faq = _faq()
|
||||
article = _graph(
|
||||
article_json_ld(_row(), faq, site, breadcrumbs(_row(), faq, "", site), None, None)
|
||||
)["TechArticle"]
|
||||
assert article["dateModified"] == article["datePublished"]
|
||||
|
||||
edited = _faq(updated_at=datetime(2026, 3, 4, tzinfo=timezone.utc))
|
||||
article = _graph(
|
||||
article_json_ld(_row(), edited, site, breadcrumbs(_row(), edited, "", site), None, None)
|
||||
)["TechArticle"]
|
||||
assert article["dateModified"] > article["datePublished"]
|
||||
|
||||
|
||||
def test_index_graph_keeps_faqpage_and_drops_mainentity_when_empty():
|
||||
site = "https://help.acme.com"
|
||||
nodes = _graph(index_json_ld(_row(), [_faq()], site, None))
|
||||
assert nodes["FAQPage"]["mainEntity"][0]["name"] == "How do I sign up?"
|
||||
|
||||
empty = _graph(index_json_ld(_row(), [], site, None))
|
||||
# An empty mainEntity is invalid FAQPage markup — omit the key entirely.
|
||||
assert "mainEntity" not in empty["FAQPage"]
|
||||
|
||||
|
||||
# ---------- preserved original article URLs ----------
|
||||
|
||||
def test_article_url_defaults_to_the_slug_path():
|
||||
assert article_url("https://help.acme.com", _faq()) == "https://help.acme.com/a/how-do-i-sign-up"
|
||||
|
||||
|
||||
def test_article_url_prefers_a_preserved_path():
|
||||
"""A migrated article advertises the URL it already ranks for, not /a/{slug}."""
|
||||
faq = _faq(url_path="/hc/en-us/articles/360012-reset")
|
||||
assert article_url("https://help.acme.com", faq) == (
|
||||
"https://help.acme.com/hc/en-us/articles/360012-reset"
|
||||
)
|
||||
|
||||
|
||||
def test_article_url_percent_encodes_but_keeps_separators():
|
||||
"""Paths are STORED decoded, so every emitted URL has to re-encode them."""
|
||||
faq = _faq(url_path="/hc/en-us/articles/café brûlée")
|
||||
assert article_url("https://help.acme.com", faq) == (
|
||||
"https://help.acme.com/hc/en-us/articles/caf%C3%A9%20br%C3%BBl%C3%A9e"
|
||||
)
|
||||
|
||||
|
||||
def test_article_href_prefixes_the_base_path():
|
||||
faq = _faq(url_path="/hc/articles/1")
|
||||
assert article_href(faq) == "/hc/articles/1"
|
||||
assert article_href(faq, "/help/acme") == "/help/acme/hc/articles/1"
|
||||
|
||||
|
||||
def test_breadcrumbs_and_json_ld_follow_the_preserved_url():
|
||||
"""The whole SEO surface has to agree on one canonical article URL."""
|
||||
row, faq = _row(), _faq(url_path="/hc/articles/1")
|
||||
site = "https://help.acme.com"
|
||||
crumbs = breadcrumbs(row, faq, "", site)
|
||||
assert crumbs[-1]["url"] == f"{site}/hc/articles/1"
|
||||
|
||||
graph = article_json_ld(row, faq, site, crumbs, None, None)
|
||||
page = next(n for n in graph["@graph"] if n["@type"] == "WebPage")
|
||||
article = next(n for n in graph["@graph"] if n["@type"] == "TechArticle")
|
||||
assert page["url"] == f"{site}/hc/articles/1"
|
||||
assert page["@id"] == f"{site}/hc/articles/1#webpage"
|
||||
assert article["@id"] == f"{site}/hc/articles/1#article"
|
||||
Reference in new issue
Block a user