How to install
{filler}
- Download the app.
- Run the installer.
See pricing for plans.
""" 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. Article-mode import tests: link discovery bounds, HTML→Markdown conversion (link absolutization, image placeholder/re-host, data: skip), and the end-to-end job with mocked fetches. """ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import urlparse import pytest from app.models.faq import FAQ, FAQStatus from app.models.faq_generation_job import FAQGenerationJob, FAQJobType from app.repositories.faq import FAQRepository from app.services import faq_article_import from app.services.faq_article_import import ( _ArticleConverter, _article_key, _remap_source_links, discover_article_links, fetch_article, run_article_import_job, ) def _response(text="", url="https://help.example.com/", content=b"", content_type="text/html"): response = MagicMock() response.text = text response.url = url response.content = content response.headers = {"content-type": content_type} response.raise_for_status = MagicMock() return response # Chatwoot-style homepage: category cards, each a section heading + article # links, plus a curated "Featured Articles" cross-cut and a category page link. INDEX_HTML = """
{filler}
{filler}
See pricing for plans.
{'x' * 600}
{many}{'x' * 600}
PayRefund
')
assert converter.images == {}
assert "pic" in markdown and " "
"[Home](https://help.paywithatoa.co.uk/hc/atoa/en) "
"[Account](https://help.paywithatoa.co.uk/hc/atoa/en/categories/account-) "
"[QR in-store](https://paywithatoa.co.uk/in-store-qr/) " # main site, NOT the help center
"[Stripe](https://stripe.com/docs) "
""
)
out = _remap_source_links(md, {"help.paywithatoa.co.uk"}, key_to_slug)
assert "[Add staff](/a/how-do-i-add-employees)" in out # root-relative, no origin
assert "[Home]" not in out # breadcrumb dropped
assert "[Account]" not in out # category breadcrumb dropped
assert "[QR in-store](https://paywithatoa.co.uk/in-store-qr/)" in out # main site kept
assert "[Stripe](https://stripe.com/docs)" in out # off-site untouched
assert "help.paywithatoa.co.uk" not in out
assert "" in out # image embed untouched
def test_remap_source_links_handles_markdown_titles():
"""markdownify emits `[x](url "title")` for anchors with a title attribute —
a titled help-center link must still be remapped, and a titled off-site link
kept intact."""
key_to_slug = {"1711032863": "how-do-i-add-employees"}
md = (
'[Add staff](https://help.paywithatoa.co.uk/hc/atoa/articles/1711032863-x "Tip") '
'[Docs](https://stripe.com/docs "Stripe docs")'
)
out = _remap_source_links(md, {"help.paywithatoa.co.uk"}, key_to_slug)
assert "[Add staff](/a/how-do-i-add-employees)" in out # remapped despite title
assert '[Docs](https://stripe.com/docs "Stripe docs")' in out # off-site title preserved
assert "help.paywithatoa.co.uk" not in out
@pytest.mark.asyncio
async def test_article_import_job_inserts_drafts(db, test_organization):
job = FAQGenerationJob(
organization_id=test_organization.id,
job_type=FAQJobType.IMPORT_ARTICLES.value,
source_url="https://help.example.com/articles",
)
db.add(job)
db.commit()
db.refresh(job)
# Duplicate-title article must dedup against existing FAQs.
FAQRepository(db).create(FAQ(
organization_id=test_organization.id, question="How to install",
answer="old", category="Guides",
))
articles = {
"https://help.example.com/articles/one": faq_article_import.Article(
url="https://help.example.com/articles/one",
title="How to install", markdown="dupe", category_hint="Guides",
),
"https://help.example.com/articles/two": faq_article_import.Article(
url="https://help.example.com/articles/two",
title="How billing works", markdown="**Billing** steps.", category_hint="Billing",
),
}
# discover now yields (url, category) pairs; the category flows to fetch_article.
discovered = [(url, "Billing") for url in articles]
with patch.object(faq_article_import, "discover_article_links", return_value=discovered), \
patch.object(faq_article_import, "fetch_article", side_effect=lambda c, url, category: articles[url]):
created = await run_article_import_job(db, job)
assert created == 1
row = db.query(FAQ).filter(FAQ.question == "How billing works").one()
assert row.status == FAQStatus.DRAFT.value
assert row.answer == "**Billing** steps."
assert row.source_label == "Imported from help.example.com"
assert row.knowledge_id is None
@pytest.mark.asyncio
async def test_article_import_job_remaps_cross_links(db, test_organization):
"""Cross-links remap even when the typed index host differs from the ACTUAL
article host (redirect / main-site index → help subdomain): article link →
/a/{new-slug}, home breadcrumb dropped, main-site link KEPT."""
from app.models.help_center import HelpCenterSettings
db.add(HelpCenterSettings(
organization_id=test_organization.id, slug="acme", enabled=True, brand_color="#4338CA",
))
job = FAQGenerationJob(
organization_id=test_organization.id,
job_type=FAQJobType.IMPORT_ARTICLES.value,
source_url="https://example.com/help", # main-site index, NOT the article host
)
db.add(job)
db.commit()
db.refresh(job)
articles = {
"https://help.example.com/hc/articles/1711-install": faq_article_import.Article(
url="https://help.example.com/hc/articles/1711-install", # real host = help subdomain
title="How to install", markdown="Install steps.", category_hint="Guides",
),
"https://help.example.com/hc/articles/2822-billing": faq_article_import.Article(
url="https://help.example.com/hc/articles/2822-billing",
title="How billing works",
markdown=(
"See [install](https://help.example.com/hc/articles/1711-install), "
"[home](https://help.example.com/hc/en), "
"and [pricing](https://example.com/pricing)."
),
category_hint="Billing",
),
}
discovered = [(url, None) for url in articles]
with patch.object(faq_article_import, "discover_article_links", return_value=discovered), \
patch.object(faq_article_import, "fetch_article", side_effect=lambda c, url, category: articles[url]):
created = await run_article_import_job(db, job)
assert created == 2
install = db.query(FAQ).filter(FAQ.question == "How to install").one()
billing = db.query(FAQ).filter(FAQ.question == "How billing works").one()
# Article link → root-relative /a/{slug} (prefix added at render); home dropped.
assert f"(/a/{install.slug})" in billing.answer
assert "http://localhost:8000" not in billing.answer # no origin baked in
assert "[home]" not in billing.answer # breadcrumb dropped
assert "help.example.com" not in billing.answer # source-host links remapped/dropped
assert "[pricing](https://example.com/pricing)" in billing.answer # main-site link KEPT
def test_remap_cleans_breadcrumb_separator_residue():
"""Dropping "Home › Account" links must not leave a stray "› ›" line."""
md = "[Home](https://help.x.co/en) › [Account](https://help.x.co/categories/2-a)\n\nReal content."
out = _remap_source_links(md, {"help.x.co"}, {})
assert "›" not in out
assert out.lstrip().startswith("Real content.")
# ---------- preserving the source help center's URLs ----------
def _import_job(db, org_id, **kw):
job = FAQGenerationJob(
organization_id=org_id,
job_type=FAQJobType.IMPORT_ARTICLES.value,
source_url="https://help.example.com/articles",
**kw,
)
db.add(job)
db.commit()
db.refresh(job)
return job
async def _run_import(db, job, articles):
discovered = [(url, "Billing") for url in articles]
with patch.object(faq_article_import, "discover_article_links", return_value=discovered), \
patch.object(faq_article_import, "fetch_article", side_effect=lambda c, url, category: articles[url]):
return await run_article_import_job(db, job)
def _article(url, title):
return faq_article_import.Article(
url=url, title=title, markdown="Body.", category_hint="Billing",
)
@pytest.mark.asyncio
async def test_article_import_preserves_original_urls_when_enabled(db, test_organization):
"""The migrated article is served at the exact path it had on the old help
center, so the org keeps the URL it already ranks for."""
job = _import_job(db, test_organization.id, preserve_source_urls=True)
url = "https://help.example.com/hc/en-us/articles/360012-reset?utm=x"
articles = {url: _article(url, "How billing works")}
assert await _run_import(db, job, articles) == 1
row = db.query(FAQ).filter(FAQ.question == "How billing works").one()
assert row.url_path == "/hc/en-us/articles/360012-reset" # query string dropped
assert row.source_url == url
assert row.slug # still assigned — /a/{slug} stays the alias that 301s
@pytest.mark.asyncio
async def test_article_import_records_source_url_but_no_path_when_disabled(db, test_organization):
"""Provenance is always captured, so preserved paths can be applied later."""
job = _import_job(db, test_organization.id)
url = "https://help.example.com/hc/en-us/articles/360012-reset"
articles = {url: _article(url, "How billing works")}
assert await _run_import(db, job, articles) == 1
row = db.query(FAQ).filter(FAQ.question == "How billing works").one()
assert row.url_path is None
assert row.source_url == url
@pytest.mark.asyncio
async def test_article_import_falls_back_to_the_slug_on_an_unusable_path(db, test_organization):
"""One URL that would shadow a real route must not cost the org the import."""
job = _import_job(db, test_organization.id, preserve_source_urls=True)
reserved = "https://help.example.com/ask"
good = "https://help.example.com/hc/articles/2"
articles = {reserved: _article(reserved, "Reserved"), good: _article(good, "Fine")}
assert await _run_import(db, job, articles) == 2
assert db.query(FAQ).filter(FAQ.question == "Reserved").one().url_path is None
assert db.query(FAQ).filter(FAQ.question == "Fine").one().url_path == "/hc/articles/2"
@pytest.mark.asyncio
async def test_article_import_still_stores_cross_links_as_slug_paths(db, test_organization):
"""Bodies must keep /a/{slug}: it's the only form the render-time prefixer
can handle in path mode, and it survives a later path edit."""
from app.models.help_center import HelpCenterSettings
db.add(HelpCenterSettings(
organization_id=test_organization.id, slug="acme", enabled=True, brand_color="#4338CA",
))
job = _import_job(db, test_organization.id, preserve_source_urls=True)
one = "https://help.example.com/hc/articles/1"
two = "https://help.example.com/hc/articles/2"
articles = {
one: _article(one, "First"),
two: faq_article_import.Article(
url=two, title="Second", markdown=f"See [first]({one}).", category_hint="Billing",
),
}
assert await _run_import(db, job, articles) == 2
first = db.query(FAQ).filter(FAQ.question == "First").one()
second = db.query(FAQ).filter(FAQ.question == "Second").one()
assert first.url_path == "/hc/articles/1"
assert f"(/a/{first.slug})" in second.answer
assert "/hc/articles/1" not in second.answer