Files
replypier/backend/tests/services/test_faq_import.py
WangShen007 164f5f9615 test: 补充权限、消息与会话服务回归用例
会话读取、接管、重分配与 Socket 订阅统一校验角色、组织与可见性。
2026-10-08 22:35:00 +08:00

213 lines
8.5 KiB
Python

"""
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.
FAQ import service tests: HTML-to-text cleanup, paragraph-safe batching, SSRF
propagation, and the end-to-end import job with a mocked model.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from bs4 import BeautifulSoup
from app.agents.faq_generator import GeneratedFAQ
from app.knowledge.url_safety import BlockedHostError
from app.models.faq import FAQ, FAQStatus
from app.models.faq_generation_job import FAQGenerationJob, FAQJobType
from app.services import faq_import
from app.services.faq_generation import pack_batches
from app.services.faq_import import _soup_to_text, _split_blocks, run_import_job
def test_soup_to_text_strips_boilerplate_and_separates_blocks():
html = """
<html><head><script>var x=1;</script><style>.a{}</style></head>
<body><nav>Home | About</nav>
<main><h2>Billing</h2><p>How do I pay?</p><p>Use a card.</p></main>
<footer>© Example</footer></body></html>
"""
text = _soup_to_text(BeautifulSoup(html, "html.parser"))
assert "How do I pay?" in text and "Billing" in text
assert "var x=1" not in text and "Home | About" not in text and "© Example" not in text
# Block elements are blank-line separated so batching keeps blocks whole.
assert "\n\n" in text
assert "\n\n\n" not in text
def test_block_batching_keeps_blocks_whole():
blocks = [f"Question {i}?\nAnswer {i} " + "x" * 80 for i in range(10)]
text = "\n\n".join(blocks)
batches = pack_batches(_split_blocks(text), max_chars=300, sep="\n\n")
assert len(batches) > 1
# No Q&A block is cut across batches.
for i in range(10):
assert sum(f"Question {i}?" in b and f"Answer {i}" in b for b in batches) == 1
@pytest.mark.asyncio
async def test_import_job_inserts_drafts_with_source_label(db, test_organization, test_ai_config):
job = FAQGenerationJob(
organization_id=test_organization.id,
job_type=FAQJobType.IMPORT_URL.value,
source_url="https://support.example.com/faq",
)
db.add(job)
db.commit()
db.refresh(job)
extracted = [GeneratedFAQ(question="Do you offer refunds?", answer="Within 14 days.", category="Billing")]
mock_generator = SimpleNamespace(extract_from_faq_page=AsyncMock(return_value=extracted), batch_chars=15000)
with patch.object(faq_import, "build_generator", return_value=mock_generator), \
patch.object(faq_import, "fetch_page_text", return_value="Do you offer refunds?\nWithin 14 days."):
created = await run_import_job(db, job)
assert created == 1
row = db.query(FAQ).filter(FAQ.organization_id == test_organization.id).one()
assert row.status == FAQStatus.DRAFT.value
assert row.knowledge_id is None
assert row.source_label == "Imported from support.example.com"
@pytest.mark.asyncio
async def test_import_job_falls_back_to_generate_when_no_verbatim_qa(db, test_organization, test_ai_config):
"""An index/prose page has no verbatim Q&A, so extract_from_faq_page returns
nothing. Rather than import 0, the batch is drafted with generate_from_text."""
job = FAQGenerationJob(
organization_id=test_organization.id,
job_type=FAQJobType.IMPORT_URL.value,
source_url="https://help.example.com/",
)
db.add(job)
db.commit()
db.refresh(job)
drafted = [GeneratedFAQ(question="Can I use it in my shop?", answer="Yes.", category="General")]
mock_generator = SimpleNamespace(
extract_from_faq_page=AsyncMock(return_value=[]), # no verbatim Q&A on the page
generate_from_text=AsyncMock(return_value=drafted), # fallback drafts from content
batch_chars=15000,
)
with patch.object(faq_import, "build_generator", return_value=mock_generator), \
patch.object(faq_import, "fetch_page_text", return_value="Industry specific\nUsing it in shops\nView all"):
created = await run_import_job(db, job)
assert created == 1
mock_generator.extract_from_faq_page.assert_awaited() # verbatim tried first
mock_generator.generate_from_text.assert_awaited() # then fell back
row = db.query(FAQ).filter(FAQ.organization_id == test_organization.id).one()
assert row.question == "Can I use it in my shop?"
@pytest.mark.asyncio
async def test_import_job_propagates_ssrf_block(db, test_organization, test_ai_config):
job = FAQGenerationJob(
organization_id=test_organization.id,
job_type=FAQJobType.IMPORT_URL.value,
source_url="https://internal.example/faq",
)
db.add(job)
db.commit()
db.refresh(job)
with patch.object(faq_import, "build_generator", return_value=SimpleNamespace()), \
patch.object(faq_import, "fetch_page_text", side_effect=BlockedHostError("blocked")):
with pytest.raises(BlockedHostError):
await run_import_job(db, job)
def test_fetch_page_text_ssrf_guard_blocks_before_fetch():
"""The real fetch path consults url_safety on the initial URL."""
with patch("app.services.faq_import.safe_get", side_effect=BlockedHostError("blocked")), \
patch.object(faq_import, "_fetch_rendered_text", return_value=None):
with pytest.raises((BlockedHostError, ValueError)):
faq_import.fetch_page_text("https://169.254.169.254/faq")
# ---------- PDF import ----------
def _tiny_pdf() -> bytes:
"""A minimal one-page PDF with extractable text ('Do you offer refunds? Yes.')."""
from pypdf import PdfWriter
from io import BytesIO
writer = PdfWriter()
writer.add_blank_page(width=200, height=200)
buffer = BytesIO()
writer.write(buffer)
return buffer.getvalue()
def test_pdf_to_text_reads_pages():
# A blank page yields no text; the function must cope without raising.
assert faq_import._pdf_to_text(_tiny_pdf()) == ""
@pytest.mark.asyncio
async def test_pdf_import_job_extracts_and_cleans_up(db, test_organization, test_ai_config):
job = FAQGenerationJob(
organization_id=test_organization.id,
job_type=FAQJobType.IMPORT_PDF.value,
source_url="/api/v1/uploads/help_center_imports/x.pdf",
source_file_name="refund-policy.pdf",
)
db.add(job)
db.commit()
db.refresh(job)
# PDF import GENERATES FAQs from the document (not verbatim extraction).
generated = [GeneratedFAQ(question="Do you offer refunds?", answer="Within 14 days.", category="Billing")]
mock_generator = SimpleNamespace(generate_from_text=AsyncMock(return_value=generated), batch_chars=15000)
deleted = []
async def fake_load(stored):
return b"%PDF-fake"
async def fake_delete(stored):
deleted.append(stored)
with patch.object(faq_import, "build_generator", return_value=mock_generator), \
patch.object(faq_import, "_pdf_to_text", return_value="Do you offer refunds?\n\nWithin 14 days."), \
patch("app.services.file_storage.load_upload", side_effect=fake_load), \
patch("app.services.file_storage.delete_upload", side_effect=fake_delete):
created = await faq_import.run_pdf_import_job(db, job)
assert created == 1
row = db.query(FAQ).filter(FAQ.question == "Do you offer refunds?").one()
assert row.source_label == "Imported from refund-policy.pdf"
assert deleted == [job.source_url] # import buffer removed
@pytest.mark.asyncio
async def test_pdf_import_job_fails_clearly_on_empty_text(db, test_organization, test_ai_config):
job = FAQGenerationJob(
organization_id=test_organization.id,
job_type=FAQJobType.IMPORT_PDF.value,
source_url="/api/v1/uploads/help_center_imports/y.pdf",
source_file_name="scan.pdf",
)
db.add(job)
db.commit()
db.refresh(job)
async def fake_load(stored):
return _tiny_pdf()
async def fake_delete(stored):
pass
with patch("app.services.file_storage.load_upload", side_effect=fake_load), \
patch("app.services.file_storage.delete_upload", side_effect=fake_delete):
with pytest.raises(ValueError, match="scanned images"):
await faq_import.run_pdf_import_job(db, job)