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