baodan/tests/user_product_material_test.py

286 lines
9.1 KiB
Python
Raw Normal View History

'''用户产品小册子上传、解析、确认和来源兼容回归测试。'''
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')