286 lines
9.1 KiB
Python
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')
|