2026-10-22 16:49:25 +08:00
"""Regression tests for V4: theme layouts, migration audit, assembly, WXR.
No external HTTP/DNS or Docker engine required.
"""
import io , json , zipfile
from pathlib import Path
from html import escape
from openpyxl import Workbook
from app import db , blueprints , theme_store , migration_audit , wordpress_compat
from test_factory_v2 import factory , site
def test_nested_grid_validates_and_builds ( factory ):
sid = site ( factory )
home = next ( x for x in factory . get ( f '/api/sites/ { sid } /entries' ) . json () if x [ 'route' ] == '/' )
layout = blueprints . new_block ( 'layout' ,{ 'columns' :[
{ 'width' : 2 , 'blocks' :[ blueprints . new_block ( 'richtext' ,{ 'title' : '品牌故事' , 'body' : '<p>我们的企业介绍</p>' })]},
{ 'width' : 1 , 'blocks' :[ blueprints . new_block ( 'cta' ,{ 'title' : '欢迎合作' , 'href' : '/contact/' })]}]})
result = factory . put ( f '/api/sites/ { sid } /pages/ { home [ "id" ] } /blueprint' , json = { 'blocks' :[ layout ],
'page_template' : 'home' , 'layout_mode' : 'replace' })
assert result . status_code == 200 , result . text
assert result . json ()[ 'layout_mode' ] == 'replace'
preview = factory . post ( f '/api/sites/ { sid } /pages/ { home [ "id" ] } /editor-preview' , json = { 'blocks' :[ layout ], 'page_template' : 'home' , 'layout_mode' : 'replace' })
assert preview . status_code == 200 , preview . text
assert '品牌故事' in preview . text and 'sf-layout-grid' in preview . text and 'pro-header' in preview . text
version = factory . post ( f '/api/sites/ { sid } /builds' , json = {}) . json ()[ 'version' ]
published = factory . get ( f '/preview/ { sid } / { version } /' ) . text
assert '品牌故事' in published and 'sf-layout-grid' in published and 'pro-header' in published
css = factory . get ( f '/preview/ { sid } / { version } /assets/blocks.css' ) . text
assert '@media(max-width:540px)' in css
def test_layout_rejects_duplicate_and_nested_excess ( factory ):
a = blueprints . new_block ( 'richtext' ); b = blueprints . new_block ( 'richtext' ); b [ 'id' ] = a [ 'id' ]
bad = blueprints . new_block ( 'layout' ,{ 'columns' :[{ 'width' : 1 , 'blocks' :[ a , b ]}]})
try : blueprints . validate_blocks ([ bad ]); assert False , 'duplicate should be rejected'
except ValueError : pass
nested = blueprints . new_block ( 'layout' ); child = blueprints . new_block ( 'layout' ); grand = blueprints . new_block ( 'layout' )
child [ 'columns' ][ 0 ][ 'blocks' ] = [ grand ]; nested [ 'columns' ][ 0 ][ 'blocks' ] = [ child ]
try : blueprints . validate_blocks ([ nested ]); assert False , 'deep nesting should be rejected'
except ValueError : pass
def test_wordpress_elementor_media_taxonomy_and_woo_gallery ( factory ):
sid = site ( factory )
widgets = json . dumps ([{ 'widgetType' : 'heading' , 'settings' :{ 'title' : '核心价值' , 'header_size' : 'h2' }},
{ 'widgetType' : 'image' , 'settings' :{ 'image' :{ 'url' : 'https://img.example/media.jpg' }}},
{ 'widgetType' : 'unknown_plugin_widget' , 'settings' :{ 'value' : 'secret' }}], ensure_ascii = False )
wxr = '''<?xml version="1.0"?><rss xmlns:wp="http://wordpress.org/export/1.2/" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel>
<item><title>图片</title><wp:post_type>attachment</wp:post_type><wp:post_id>12</wp:post_id><wp:attachment_url>https://img.example/cover.jpg</wp:attachment_url></item>
<item><title>产品A</title><link>https://wp.example/product/test-a/</link><wp:post_type>product</wp:post_type><wp:status>publish</wp:status><content:encoded></content:encoded>
<category domain="product_cat">运动型多用途车</category>
<wp:postmeta><wp:meta_key>_thumbnail_id</wp:meta_key><wp:meta_value>12</wp:meta_value></wp:postmeta>
<wp:postmeta><wp:meta_key>_product_image_gallery</wp:meta_key><wp:meta_value>12</wp:meta_value></wp:postmeta>
<wp:postmeta><wp:meta_key>_elementor_data</wp:meta_key><wp:meta_value><![CDATA[''' + widgets + ''']]></wp:meta_value></wp:postmeta>
<wp:postmeta><wp:meta_key>_price</wp:meta_key><wp:meta_value>1200</wp:meta_value></wp:postmeta>
</item></channel></rss>'''
response = factory . post ( f '/api/sites/ { sid } /import' , files = { 'file' :( 'export.xml' , wxr . encode (), 'application/xml' )})
assert response . status_code == 200 , response . text
item = next ( x for x in factory . get ( f '/api/sites/ { sid } /entries' ) . json () if x [ 'title' ] == '产品A' )
assert item [ 'cover' ] == 'https://img.example/cover.jpg'
assert item [ 'meta' ][ 'fields' ][ 'category' ] == '运动型多用途车'
assert item [ 'meta' ][ 'fields' ][ 'price' ] == '1200'
assert '核心价值' in item [ 'body' ]
assert item [ 'meta' ][ 'migration_warnings' ]
assert item [ 'meta' ][ 'fields' ][ 'gallery' ] == [ 'https://img.example/cover.jpg' ]
def test_excel_mapping_relations_preview_and_repeat ( factory ):
sid = site ( factory )
w = Workbook (); ws = w . active ; ws . append ([ '车型名称' , '品牌' , '分类' , '动力形式' , '图库' , '原网址' ])
ws . append ([ 'SUV Alpha' , 'Hesu' , 'SUV' , 'PHEV' , 'https://img.example/1.jpg;https://img.example/2.jpg' , '/cars/alpha.html' ])
b = io . BytesIO (); w . save ( b )
r = factory . post ( f '/api/factory/sites/ { sid } /mapping/parse-file' , files = { 'file' :( 'cars.xlsx' , b . getvalue ())})
assert r . status_code == 200 , r . text
parsed = r . json (); assert parsed [ 'count' ] == 1
assert parsed [ 'suggested' ][ 'title' ] == '车型名称'
report = factory . post ( f '/api/factory/sites/ { sid } /mapping/run' , json = { 'rows' : parsed [ 'rows' ],
'rules' :{ ** parsed [ 'suggested' ], 'gallery' : '图库' }, 'content_type' : 'vehicle' , 'dry_run' : True }) . json ()
assert report [ 'mapped' ] == 1 and report [ 'sample' ][ 0 ][ 'route' ] == '/cars/alpha.html'
committed = factory . post ( f '/api/factory/sites/ { sid } /mapping/run' , json = { 'rows' : parsed [ 'rows' ],
'rules' :{ ** parsed [ 'suggested' ], 'gallery' : '图库' }, 'content_type' : 'vehicle' , 'dry_run' : False })
assert committed . status_code == 200
x = factory . get ( f '/api/factory/sites/ { sid } /relationships' ) . json ()
assert any ( i [ 'brand' ] == 'Hesu' for i in x [ 'brand_pages_missing' ])
y = factory . post ( f '/api/factory/sites/ { sid } /relationships/assemble' ) . json ()
assert y [ 'count' ] == 1
assert factory . post ( f '/api/factory/sites/ { sid } /relationships/assemble' ) . json ()[ 'count' ] == 0
entries = factory . get ( f '/api/sites/ { sid } /entries' ) . json ()
assert any ( e [ 'type' ] == 'brand' and e [ 'title' ] == 'Hesu' for e in entries )
assert next ( e for e in entries if e [ 'title' ] == 'SUV Alpha' )[ 'meta' ][ 'fields' ][ 'gallery' ] == [ 'https://img.example/1.jpg' , 'https://img.example/2.jpg' ]
for theme in ( 'industrial' , 'corporate' , 'minimal' ):
assert factory . put ( f '/api/factory/sites/ { sid } /theme' , json = { 'theme' : theme }) . status_code == 200
version = factory . post ( f '/api/sites/ { sid } /builds' , json = {}) . json ()[ 'version' ]
assert 'SUV Alpha' in factory . get ( f '/preview/ { sid } / { version } /cars/alpha.html' ) . text
def test_migration_manifest_and_built_qa_blocks_missing ( factory ):
sid = site ( factory , mode = 'migration' )
baseline = 'url,seo_title,description,canonical,status \n https://old.example/,Title from old,Old description,,200 \n https://old.example/legacy-only.html,Missing,,,200 \n '
r = factory . post ( f '/api/audit/sites/ { sid } /import-baseline' , files = { 'file' :( 'urls.csv' , baseline . encode ())})
assert r . status_code == 200 , r . text
assert r . json ()[ 'imported' ] == 2
version = factory . post ( f '/api/sites/ { sid } /builds' , json = {}) . json ()[ 'version' ]
report = factory . get ( f '/api/audit/sites/ { sid } /builds/ { version } /report' ) . json ()
assert not report [ 'passed' ] and report [ 'errors' ] >= 1
assert any ( i [ 'route' ] == '/legacy-only.html' and i [ 'field' ] == 'url' for i in report [ 'issues' ])
assert any ( i [ 'route' ] == '/' and i [ 'severity' ] == 'warning' for i in report [ 'issues' ])
readiness = factory . get ( f '/api/factory/sites/ { sid } /migration/readiness?build_id= { version } ' ) . json ()
assert not readiness [ 'can_publish' ]
csv = factory . get ( f '/api/audit/sites/ { sid } /builds/ { version } /report.csv' )
assert csv . status_code == 200 and 'legacy-only.html' in csv . text
def test_sitemap_import_and_invalid_baseline_atomic ( factory ):
sid = site ( factory , mode = 'migration' )
xml = '''<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://old.example/about/</loc></url></urlset>'''
assert factory . post ( f '/api/audit/sites/ { sid } /import-baseline' , files = { 'file' :( 'sitemap.xml' , xml . encode ())}) . json ()[ 'imported' ] == 1
bad = 'url \n https://old.example/about/ \n https://old.example/about/?page=5 \n '
response = factory . post ( f '/api/audit/sites/ { sid } /import-baseline' , files = { 'file' :( 'bad.csv' , bad . encode ())})
assert response . status_code == 200 and response . json ()[ 'errors' ]
baseline = factory . get ( f '/api/enterprise/sites/ { sid } /baseline/report' ) . json ()
assert baseline [ 'baseline_count' ] == 1
def test_theme_gallery_and_compatibility ( factory ):
sid = site ( factory )
for theme in ( 'automotive' , 'industrial' , 'corporate' , 'minimal' ):
r = factory . get ( f '/api/factory/themes/ { theme } /compatibility' )
assert r . status_code == 200 and r . json ()[ 'valid' ], r . text
product = factory . post ( f '/api/sites/ { sid } /entries' , json = { 'title' : 'Unique Product' , 'type' : 'product' , 'route' : '/product/unique/' ,
'cover' : 'https://imgs.example/cover.jpg' , 'meta' :{ 'fields' :{ 'gallery' :[ 'https://imgs.example/cover.jpg' , 'https://imgs.example/second.jpg' ]}}})
assert product . status_code in ( 200 , 201 ), product . text
ver = factory . post ( f '/api/sites/ { sid } /builds' , json = {}) . json ()[ 'version' ]
html = factory . get ( f '/preview/ { sid } / { ver } /product/unique/' ) . text
assert 'pro-gallery-thumbs' in html and 'data-gallery-image' in html
2026-10-26 09:43:17 +08:00
assert 'auto-compiled.js' in html or 'pro.js' in html
2026-10-22 16:49:25 +08:00
def test_css_style_breakout_rejected ( factory ):
z = io . BytesIO ()
with zipfile . ZipFile ( z , 'w' ) as f :
data = { 'id' : 'test-css-escape' , 'version' : '1.0.0' , 'name' : 'Safe Test' , 'layout_family' : 'corporate' }
f . writestr ( 'manifest.json' , json . dumps ( data )); f . writestr ( 'page-presets.json' , json . dumps ({ 'home' :[]}))
f . writestr ( 'theme.css' , 'body{color:red}</style><script>alert(1)</script>' )
result = factory . post ( '/api/factory/themes/install' , files = { 'file' :( 'unsafe.zip' , z . getvalue ())})
assert result . status_code == 400
def test_migration_warning_requires_explicit_review ( factory ):
sid = site ( factory , mode = 'migration' )
source = 'url,seo_title \n https://old.example/,Old SEO Title \n '
factory . post ( f '/api/audit/sites/ { sid } /import-baseline' , files = { 'file' :( 'old.csv' , source . encode ())})
build = factory . post ( f '/api/sites/ { sid } /builds' , json = {}) . json ()[ 'version' ]
current = factory . get ( f '/api/audit/sites/ { sid } /builds/ { build } /report' ) . json ()
warnings = [ i for i in current [ 'issues' ] if i [ 'severity' ] == 'warning' and i [ 'field' ] == 'title' ]
assert warnings and current [ 'unreviewed_warnings' ] >= 1
readiness = factory . get ( f '/api/factory/sites/ { sid } /migration/readiness?build_id= { build } ' ) . json ()
assert not readiness [ 'can_publish' ]
assert any ( '未经审核' in x [ 'message' ] for x in readiness [ 'gates' ])
denied = factory . post ( f '/api/audit/sites/ { sid } /builds/ { build } /review-warning' , json = {
'issue_id' : warnings [ 0 ][ 'issue_id' ], 'reason' : '短' });
assert denied . status_code == 400
accepted = factory . post ( f '/api/audit/sites/ { sid } /builds/ { build } /review-warning' , json = {
'issue_id' : warnings [ 0 ][ 'issue_id' ], 'reason' : '企业管理人员已经核查该 SEO 标题调整,并批准本构建中的改动。' })
assert accepted . status_code == 200 , accepted . text
after = factory . get ( f '/api/audit/sites/ { sid } /builds/ { build } /report' ) . json ()
assert after [ 'unreviewed_warnings' ] == 0
next_gate = factory . get ( f '/api/factory/sites/ { sid } /migration/readiness?build_id= { build } ' ) . json ()
assert not any ( '未经审核' in x [ 'message' ] for x in next_gate [ 'gates' ])
# Unrelated broken links may still correctly block publication.
def test_site_scoped_reusable_block_library ( factory ):
first = site ( factory ); second = site ( factory )
block = blueprints . new_block ( 'layout' ,{ 'columns' :[{ 'width' : 1 , 'blocks' :[ blueprints . new_block ( 'richtext' ,{ 'title' : '跨页面共用模块' })]}]})
created = factory . post ( f '/api/sites/ { first } /components' , json = { 'name' : '品牌单列布局' , 'block' : block })
assert created . status_code == 200 , created . text
cid = created . json ()[ 'id' ]
rows = factory . get ( f '/api/sites/ { first } /components' ) . json ()
assert len ( rows ) == 1 and rows [ 0 ][ 'block' ][ 'columns' ][ 0 ][ 'blocks' ][ 0 ][ 'title' ] == '跨页面共用模块'
assert not factory . get ( f '/api/sites/ { second } /components' ) . json ()
assert factory . put ( f '/api/sites/ { first } /components/ { cid } ' , json = { 'name' : '新版' , 'expected_version' : 0 , 'block' : block }) . status_code == 409
assert factory . put ( f '/api/sites/ { first } /components/ { cid } ' , json = { 'name' : '新版' , 'expected_version' : 1 , 'block' : block }) . json ()[ 'version' ] == 2
assert factory . delete ( f '/api/sites/ { first } /components/ { cid } ' ) . json ()[ 'deleted' ]
def test_contact_form_requires_configured_https_endpoint ( factory ):
sid = site ( factory )
assert factory . put ( f '/api/sites/ { sid } /contact-settings' , json = { 'email' : 'hello@example.org' , 'form_endpoint' : 'http://unsecure.example/forms' }) . status_code == 400
config = factory . put ( f '/api/sites/ { sid } /contact-settings' , json = {
'email' : 'hello@example.org' , 'form_endpoint' : 'https://forms.example.org/receive' , 'footer_text' : 'Official inquiry channel' })
assert config . status_code == 200 , config . text
factory . post ( f '/api/factory/sites/ { sid } /bootstrap' )
build = factory . post ( f '/api/sites/ { sid } /builds' , json = {}) . json ()[ 'version' ]
contact = factory . get ( f '/preview/ { sid } / { build } /contact/' ) . text
assert '<form' in contact and 'forms.example.org/receive' in contact
assert 'Official inquiry channel' in contact