baodan/tests/ppt_task_lifecycle_test.py
wsb1224 2b54d078f2 针对“A 账号生成、B 账号也能看到”的问题,目前不会再发生,前提是 A、B 都通过保险前端各自重新登录。
当前已验证:
用户 1 和用户 3 的 PPT、海报、任务列表完全隔离。
未登录或访客身份会直接返回 401。
任务详情、下载、工作区操作都校验所属用户。
跨用户幂等任务复用漏洞已封堵。
35 项相关测试、前端构建和部署健康检查均通过。
2026-08-01 19:38:56 +08:00

425 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""PPT 异步任务生命周期回归测试。"""
import asyncio
import sys
import types
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "api"))
from insurance.generation import task_service
from insurance.ppt import extraction as extraction_module
from insurance.ppt import regex_extractor
from insurance.ppt.extraction import ExtractionOrchestrator, ExtractionResult
from insurance.ppt.llm_client import (
LLMResponse,
_parse_json_content,
_parse_timeout_ms,
_validate_required_fields,
)
from insurance.ppt.prompts import select_key_pages
class _Field:
def in_(self, _values):
return self
class _Query:
def filter_by(self, **_kwargs):
return self
def filter(self, *_args):
return self
def first(self):
return None
class _Session:
def __init__(self):
self.commits = 0
def add(self, _value):
return None
def commit(self):
self.commits += 1
class _GenerationTask:
status = _Field()
query = _Query()
def __init__(self, **kwargs):
self.id = "task-1"
self.status = "queued"
self.error_code = ""
self.error_message = None
self.finished_at = None
for key, value in kwargs.items():
setattr(self, key, value)
def to_dict(self):
return {
"id": self.id,
"status": self.status,
"errorCode": self.error_code,
"errorMessage": self.error_message,
}
def test_dispatch_failure_finishes_task(monkeypatch):
"""消息队列不可用时,不应留下永久 queued 任务。"""
fake_db = types.SimpleNamespace(session=_Session())
compat_module = types.ModuleType("insurance.db.compat")
compat_module.db = fake_db
model_module = types.ModuleType("insurance.models.generation_task")
model_module.GenerationTask = _GenerationTask
monkeypatch.setitem(sys.modules, "insurance.db.compat", compat_module)
monkeypatch.setitem(sys.modules, "insurance.models.generation_task", model_module)
synced = []
monkeypatch.setattr(
task_service,
"_dispatch_to_celery",
lambda _task: (_ for _ in ()).throw(ConnectionError("broker unavailable")),
)
monkeypatch.setattr(
task_service,
"_sync_failed_ppt_session",
lambda task, message: synced.append((task.id, message)),
)
result = task_service.create_task(
user_id="user-1",
artifact_type="ppt",
operation="parse",
workspace_id="session-1",
)
assert result["code"] == 9999
assert result["data"]["status"] == "failed"
assert result["data"]["errorCode"] == "dispatch_failed"
assert synced and synced[0][0] == result["data"]["id"]
def test_create_task_idempotency_is_scoped_to_user(monkeypatch):
"""相同幂等键不能让一个用户复用另一个用户的任务。"""
filter_by_calls = []
created_tasks = []
other_user_task = _GenerationTask(user_id="user-1")
other_user_task.status = "done"
class OwnerAwareQuery(_Query):
def filter_by(self, **kwargs):
filter_by_calls.append(kwargs)
return _Query() if kwargs.get("user_id") == "user-2" else self
def first(self):
return other_user_task
class OwnerScopedGenerationTask(_GenerationTask):
query = OwnerAwareQuery()
def __init__(self, **kwargs):
super().__init__(**kwargs)
created_tasks.append(kwargs)
fake_db = types.SimpleNamespace(session=_Session())
compat_module = types.ModuleType("insurance.db.compat")
compat_module.db = fake_db
model_module = types.ModuleType("insurance.models.generation_task")
model_module.GenerationTask = OwnerScopedGenerationTask
monkeypatch.setitem(sys.modules, "insurance.db.compat", compat_module)
monkeypatch.setitem(sys.modules, "insurance.models.generation_task", model_module)
monkeypatch.setattr(task_service, "_dispatch_to_celery", lambda _task: None)
result = task_service.create_task(
user_id="user-2",
artifact_type="ppt",
operation="generate",
workspace_id="session-2",
idempotency_key="shared-key",
)
assert result["code"] == 0
assert created_tasks[0]["user_id"] == "user-2"
assert {"user_id": "user-2", "idempotency_key": "shared-key"} in filter_by_calls
def test_cached_extraction_reports_completion(tmp_path, monkeypatch):
"""重复解析应命中缓存,并向调用方报告完成阶段。"""
pdf_path = tmp_path / "plan.pdf"
pdf_path.write_bytes(b"%PDF-1.4")
cached = ExtractionResult(
pdf_path=str(pdf_path),
product_name="测试产品",
plan_type="savings",
status="success",
data={"product_name": "测试产品"},
)
orchestrator = ExtractionOrchestrator()
monkeypatch.setattr(orchestrator, "_load_from_cache", lambda _path: cached)
updates = []
result = asyncio.run(orchestrator.extract_plan(
str(pdf_path),
progress_callback=lambda progress, message: updates.append((progress, message)),
))
assert result is cached
assert updates == [(100, "已使用历史解析结果")]
def test_default_llm_timeout_is_bounded():
assert _parse_timeout_ms(None) == 180_000
assert _parse_timeout_ms("30000") == 30_000
def test_llm_json_parser_accepts_explanation_and_trailing_comma():
content = '结果如下:\n```json\n{"product_name": "测试产品",}\n```\n请核对。'
assert _parse_json_content(content) == {"product_name": "测试产品"}
def test_structured_schema_rejects_empty_or_incomplete_success_payload():
schema = {
"type": "object",
"required": ["insured", "policy"],
"properties": {
"insured": {"type": "object", "required": ["age"]},
"policy": {"type": "object", "required": ["annual_premium"]},
},
}
with pytest.raises(ValueError, match="缺少必填字段"):
_validate_required_fields({}, schema)
with pytest.raises(ValueError, match="annual_premium"):
_validate_required_fields({"insured": {"age": 48}, "policy": {}}, schema)
_validate_required_fields(
{"insured": {"age": 48}, "policy": {"annual_premium": 80060}},
schema,
)
def test_pdf_pages_keep_page_numbers_and_select_late_benefit_page():
text = extraction_module._format_pdf_pages([
"Product Name: Example IUL",
"general terms",
"Policy Year Account Value Cash Surrender Value Death Benefit 1 1000 900 500000",
])
assert "[PAGE 1]" in text
assert "[PAGE 3]" in text
selected = select_key_pages(text, max_pages=2, max_chars=2000)
assert "Product Name: Example IUL" in selected
assert "Cash Surrender Value" in selected
def test_corrupted_pdf_text_detection_accepts_normal_text_and_rejects_font_garbage():
normal = "保险计划书 被保人年龄 48 岁\nPolicy Year 1 Cash Value 100000\n" * 3
corrupted = "\uffff\uffff\x81\x82ĤøùÿxĀ’@BQā" * 20
assert extraction_module._looks_corrupted(normal) is False
assert extraction_module._looks_corrupted(corrupted) is True
cid_garbage = "[PAGE 1]\n" + "(cid:4)(cid:17)(cid:238)(cid:99)" * 30
assert extraction_module._looks_corrupted(cid_garbage) is True
def test_iul_filename_hint_corrects_ocr_product_name():
data = {
"product_name": "SAR Feel",
"policy": {"product_name": "SAR Feel"},
}
corrected = extraction_module._apply_filename_hints(
data,
"/tmp/MLS_SIUL3_F-48-N-CN-USD-S3m-5x_coi__SC_.pdf",
"iul",
)
assert corrected["product_name"] == "Manulife SIUL 3"
assert corrected["policy"]["product_name"] == "Manulife SIUL 3"
assert corrected["insured"] == {"gender": "female", "age": 48, "smoker": "no"}
assert corrected["policy"]["currency"] == "USD"
assert corrected["policy"]["sum_insured"] == 3_000_000
assert corrected["policy"]["premium_payment_period"] == 5
def test_iul_filename_hint_does_not_override_extracted_values():
data = {
"product_name": "unknown",
"insured": {"age": 49, "gender": "female", "smoker": "no"},
"policy": {"currency": "HKD", "sum_insured": 2_000_000, "premium_payment_period": 8},
}
corrected = extraction_module._apply_filename_hints(
data,
"/tmp/MLS_SIUL3_F-48-N-CN-USD-S3m-5x.pdf",
"iul",
)
assert corrected["insured"]["age"] == 49
assert corrected["policy"]["currency"] == "HKD"
assert corrected["policy"]["sum_insured"] == 2_000_000
assert corrected["policy"]["premium_payment_period"] == 8
def test_iul_ocr_label_extracts_planned_annual_premium():
from insurance.ppt.regex_extractor import _extract_annual_premium
text = "性别 Female 首期规划保费 US$80,060.00\n偿还至形成基金所需保费 US$80,060.00 从第1年至第5年"
assert _extract_annual_premium(text) == 80_060
def test_savings_milestone_rows_are_warnings_not_blocking_errors():
from insurance.ppt.validator import validate_formal_savings_plan
plan = {
"productName": "测试储蓄计划",
"insured": {"age": 41, "smoker": "no"},
"policy": {"currency": "USD", "annualPremium": 5250, "payYears": 5},
"benefitRows": [
{"policyYear": year, "sourcePage": 3}
for year in [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 65, 70, 75, 80, 85, 90, 95, 100]
],
"withdrawalRows": [],
"source": {"pdfHash": "abc"},
}
issues = validate_formal_savings_plan(plan)
assert not [issue for issue in issues if issue.level == "error"]
assert any(issue.code == "BENEFIT_ROWS_MILESTONE_ONLY" for issue in issues)
assert any(issue.code == "BENEFIT_ROWS_DISCONTINUOUS" for issue in issues)
def test_savings_rows_without_key_years_are_non_blocking_warnings():
from insurance.ppt.validator import validate_formal_savings_plan
plan = {
"productName": "测试储蓄计划",
"insured": {"age": 41, "smoker": "no"},
"policy": {"currency": "USD", "annualPremium": 5250, "payYears": 5},
"benefitRows": [
{"policyYear": year, "sourcePage": 3}
for year in [1, 2, 3, 4, 5]
],
"withdrawalRows": [],
"source": {"pdfHash": "abc"},
}
issues = validate_formal_savings_plan(plan)
assert any(
issue.code == "BENEFIT_ROWS_INCOMPLETE" and issue.level == "warn"
for issue in issues
)
assert not [issue for issue in issues if issue.level == "error"]
def test_split_extraction_merges_identity_and_benefit_result(tmp_path, monkeypatch):
pdf_path = tmp_path / "iul.pdf"
pdf_path.write_bytes(b"%PDF-1.4")
monkeypatch.setattr(
extraction_module,
"_extract_pdf_text",
lambda _path: ("有效计划书文本" * 100, []),
)
monkeypatch.setattr(regex_extractor, "extract_insurance_regex", lambda _text: {})
monkeypatch.setattr(regex_extractor, "count_benefit_rows", lambda _data: 0)
complete = {
"product_name": "测试 IUL",
"product_type": "iul",
"insured": {"age": 35},
"policy": {"sum_insured": 500000, "index_account_rate": 0.05},
"index_accounts": [{"name": "S&P 500"}],
"benefit_illustration": [{"policy_year": 1, "account_value": 1000}],
}
async def fake_structured_output(**kwargs):
prompt = kwargs.get("prompt", "")
if "身份和保单字段" in prompt:
data = {key: value for key, value in complete.items() if key != "benefit_illustration"}
elif "利益演示表" in prompt:
data = {"benefit_illustration": complete["benefit_illustration"]}
elif "提领/提款演示表" in prompt:
data = {"withdrawal_illustration": []}
else:
data = {}
return data, LLMResponse(content="{}", provider="test")
from insurance.ppt.llm_client import llm_client
monkeypatch.setattr(llm_client, "structured_output", fake_structured_output)
result = asyncio.run(
ExtractionOrchestrator(use_cache=False).extract_plan(str(pdf_path), "iul")
)
assert result.status == "success"
assert result.product_name == "测试 IUL"
assert result.data["benefit_illustration"]
def test_generate_ppt_task_marks_failure_and_syncs_workspace(monkeypatch):
"""生成入口自身异常也必须结束任务,不能永久停在 running。"""
celery_module = types.ModuleType("celery")
def shared_task(*_args, **_kwargs):
return lambda func: func
celery_module.shared_task = shared_task
monkeypatch.setitem(sys.modules, "celery", celery_module)
sys.modules.pop("insurance.generation.celery_tasks", None)
from insurance.generation import celery_tasks
task = types.SimpleNamespace(id="task-1", workspace_id="session-1")
session = types.SimpleNamespace(workflow_step="ready")
class TaskQuery:
@staticmethod
def get(_task_id):
return task
class SessionQuery:
@staticmethod
def get(_session_id):
return session
generation_model = types.ModuleType("insurance.models.generation_task")
generation_model.GenerationTask = types.SimpleNamespace(query=TaskQuery())
session_model = types.ModuleType("insurance.models.ppt_session")
session_model.PptSession = types.SimpleNamespace(query=SessionQuery())
fake_db = types.SimpleNamespace(session=_Session())
compat_module = types.ModuleType("insurance.db.compat")
compat_module.db = fake_db
monkeypatch.setitem(sys.modules, "insurance.models.generation_task", generation_model)
monkeypatch.setitem(sys.modules, "insurance.models.ppt_session", session_model)
monkeypatch.setitem(sys.modules, "insurance.db.compat", compat_module)
monkeypatch.setattr(celery_tasks, "_claim_task", lambda _task_id: True)
updates = []
monkeypatch.setattr(
celery_tasks,
"_update_task_status",
lambda _task_id, **kwargs: updates.append(kwargs),
)
monkeypatch.setattr(
celery_tasks,
"_execute_ppt_generate",
lambda _task_id: (_ for _ in ()).throw(RuntimeError("render failed")),
)
synced = []
monkeypatch.setattr(task_service, "sync_workspace_status", lambda value: synced.append(value))
with pytest.raises(RuntimeError, match="render failed"):
celery_tasks.generate_ppt_task(None, "task-1")
assert session.workflow_step == "generating"
assert any(update.get("status") == "failed" for update in updates)
assert synced == [task]