baodan/tests/user_product_material_test.py

286 lines
9.1 KiB
Python

'''用户产品小册子上传、解析、确认和来源兼容回归测试。'''
import asyncio
import sys
import types
from pathlib import Path
import pytest
from flask import Flask
from sqlalchemy import inspect, text
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'api'))
from insurance.db.compat import db
from insurance.db.migrate_026 import migrate
from insurance.models.user_product_material import UserProductMaterial
from insurance.poster import product_material_service
from insurance.poster import manual_parser
from insurance.poster.product_material_service import validate_confirmed_rules
from insurance.poster.product_source_resolver import (
ProductSourceError,
case_matches_product,
normalize_product_source,
product_snapshot,
resolve_product_source,
)
def _valid_rules():
return {
'product_name': '安心储蓄计划',
'features': [
{'title': '长期保障', 'summary': '保障期覆盖至终身', 'source_page': 8},
],
'currency_options': ['HKD'],
'coverage_highlights': ['身故保障'],
'risk_warnings': ['收益并非保证'],
'investment_rules': {},
}
def test_confirmed_rules_require_feature_summary():
rules = _valid_rules()
rules['features'][0]['summary'] = ''
valid, message, normalized = validate_confirmed_rules(rules)
assert valid is False
assert '摘要' in message
assert normalized is None
def test_confirmed_rules_are_normalized_and_bounded():
valid, message, normalized = validate_confirmed_rules(_valid_rules())
assert valid is True
assert message == ''
assert normalized['product_name'] == '安心储蓄计划'
assert normalized['features'][0]['source_page'] == 8
def test_manual_parser_accepts_text_and_page_quality_tuple(monkeypatch):
from insurance.ppt import extraction
monkeypatch.setattr(
extraction,
'_extract_pdf_text',
lambda _path: ('产品特色和保障内容', [{'page': 1, 'quality': 'high'}]),
)
async def fake_structured_output(user_prompt, _system_prompt, schema):
assert '产品特色和保障内容' in user_prompt
assert schema['required'] == ['product_name', 'features']
return _valid_rules(), None
monkeypatch.setattr(manual_parser.llm_client, 'structured_output', fake_structured_output)
result = asyncio.run(manual_parser.parse_manual_pdf('manual.pdf'))
assert result['product_name'] == '安心储蓄计划'
def test_manual_parser_selects_marked_key_pages_with_context():
text_value = (
'[PAGE 1]\n封面\n\n'
'[PAGE 2]\n目录\n\n'
'[PAGE 3]\n产品特色:长期保障\n\n'
'[PAGE 4]\n示例说明\n\n'
'[PAGE 5]\n联系方式'
)
selected = manual_parser._select_key_pages(text_value)
assert '[PAGE 2]' in selected
assert '[PAGE 3]' in selected
assert '[PAGE 4]' in selected
assert '[PAGE 1]' not in selected
assert '[PAGE 5]' not in selected
def test_parse_error_hides_internal_paths():
material = UserProductMaterial(
owner_user_id='user-1',
original_name='manual.pdf',
file_key='uploads/manual.pdf',
sha256='a' * 64,
parse_error='File "D:\\private\\manual.pdf" failed with token=secret',
)
assert material.to_dict()['parseError'] == '解析失败,请重试;如多次失败请联系管理员'
def test_material_dict_keeps_parsed_and_confirmed_rules():
material = UserProductMaterial(
owner_user_id='user-1',
original_name='manual.pdf',
file_key='uploads/product-manuals/users/user-1/manual.pdf',
sha256='b' * 64,
parsed_rules='{"product_name": "解析产品"}',
confirmed_rules='{"product_name": "确认产品"}',
)
data = material.to_dict()
assert data['parsedRules']['product_name'] == '解析产品'
assert data['confirmedRules']['product_name'] == '确认产品'
def test_product_source_accepts_new_and_legacy_payloads():
assert normalize_product_source({
'productSource': {'type': 'user_material', 'id': 12},
}) == ('user_material', '12')
assert normalize_product_source({'productId': 'prod-1'}) == (
'library_product', 'prod-1',
)
def test_user_material_source_respects_feature_flag(monkeypatch):
monkeypatch.setenv('POSTER_USER_MANUAL_UPLOAD_ENABLED', 'false')
with pytest.raises(ProductSourceError) as exc_info:
resolve_product_source('user-1', {
'productSource': {'type': 'user_material', 'id': '12'},
})
assert exc_info.value.code == 4108
def test_product_snapshot_excludes_internal_storage_and_knowledge_fields():
snapshot = product_snapshot({
'sourceType': 'library_product',
'sourceId': 'prod-1',
'productId': 'prod-1',
'productName': '产品 A',
'companyId': 'company-1',
'companyName': '保司 A',
'planType': 'savings',
'rules': _valid_rules(),
'confirmedAt': '2026-07-31T10:00:00',
'productData': {
'id': 'prod-1',
'displayName': '产品 A',
'manualFileUrl': r'D:\private\manual.pdf',
'manualParseError': 'internal detail',
},
'companyData': {
'id': 'company-1',
'displayName': '保司 A',
'knowledgeDirectories': ['private-kb'],
'evidenceRanking': ['secret'],
},
})
assert snapshot['productData'] == {'id': 'prod-1', 'displayName': '产品 A'}
assert snapshot['companyData'] == {'id': 'company-1', 'displayName': '保司 A'}
def test_file_key_must_stay_inside_storage_root(tmp_path, monkeypatch):
monkeypatch.setattr(product_material_service, 'get_storage_root', lambda: str(tmp_path))
valid_path = product_material_service.resolve_file_key(
'uploads/product-manuals/users/user-a/manual.pdf'
)
assert valid_path.startswith(str(tmp_path))
with pytest.raises(ValueError):
product_material_service.resolve_file_key('../outside.pdf')
with pytest.raises(ValueError):
product_material_service.resolve_file_key('uploads/poster-cases/other.pdf')
def test_user_material_resolution_includes_owner_filter(monkeypatch):
class Field:
def __init__(self, name):
self.name = name
def __eq__(self, value):
return self.name, value
def is_(self, value):
return self.name, value
class Query:
conditions = ()
def filter(self, *conditions):
self.conditions = conditions
return self
def first(self):
return None
query = Query()
class FakeMaterial:
id = Field('id')
owner_user_id = Field('owner_user_id')
status = Field('status')
deleted_at = Field('deleted_at')
FakeMaterial.query = query
module = types.ModuleType('insurance.models.user_product_material')
module.UserProductMaterial = FakeMaterial
monkeypatch.setitem(sys.modules, 'insurance.models.user_product_material', module)
with pytest.raises(ProductSourceError) as exc_info:
resolve_product_source('user-2', {
'productSource': {'type': 'user_material', 'id': '9'},
})
assert exc_info.value.code == 4106
assert ('owner_user_id', 'user-2') in query.conditions
def test_case_source_matching_supports_new_and_legacy_records():
legacy_case = types.SimpleNamespace(
product_source_type=None,
product_source_id=None,
product_id='prod-1',
)
current_case = types.SimpleNamespace(
product_source_type='user_material',
product_source_id='12',
product_id=None,
)
assert case_matches_product(legacy_case, {
'sourceType': 'library_product', 'sourceId': 'prod-1',
})
assert case_matches_product(current_case, {
'sourceType': 'user_material', 'sourceId': '12',
})
def test_migrate_026_is_idempotent_and_backfills_legacy_sources():
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
with app.app_context():
db.session.execute(text(
'CREATE TABLE poster_case_uploads '
'(id INTEGER PRIMARY KEY, product_id VARCHAR(50))'
))
db.session.execute(text(
'CREATE TABLE poster_records '
'(id INTEGER PRIMARY KEY, product_id VARCHAR(50))'
))
db.session.execute(
text('INSERT INTO poster_records (id, product_id) VALUES (1, :product_id)'),
{'product_id': 'prod-1'},
)
db.session.commit()
migrate()
migrate()
inspector = inspect(db.engine)
assert inspector.has_table('insurance_user_product_materials')
record_columns = {item['name'] for item in inspector.get_columns('poster_records')}
assert {'product_source_type', 'product_source_id', 'product_snapshot_json'} <= record_columns
row = db.session.execute(text(
'SELECT product_source_type, product_source_id FROM poster_records WHERE id = 1'
)).one()
assert tuple(row) == ('library_product', 'prod-1')