chore: import SiteForge Factory V5 source (baseline)

导入 SiteForge Factory V5 基线源码:
- FastAPI + SQLite + Docker Compose(Web + Worker)架构
- 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照)
- 四种建站模式骨架 + 迁移审计 + 发布门禁
- 52 项 pytest 测试
- 补全计划主文档 docs/superpowers/plans/

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-22 10:42:19 +08:00
commit 3c57c167f2
264 changed files with 11682 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
"""Acceptance tests for V5 visual packages and honest commercial readiness."""
import json
from pathlib import Path
from bs4 import BeautifulSoup
from app import theme_store,db,builder,commercial_readiness
from test_factory_v2 import factory,site
def test_all_four_new_versions_have_complete_offline_artwork(factory):
for theme in ('automotive','industrial','corporate','minimal'):
versions=factory.get(f'/api/factory/themes/{theme}/versions').json()
names={row['version'] for row in versions}
assert {'1.1.0','1.0.0','0.3.0'} <= names
folder=theme_store.resolve(theme,'1.1.0')
assert (folder/'visual/hero.svg').read_text(encoding='utf-8').startswith('<svg ')
assert (folder/'pages/product.html').is_file()
assert theme_store.compatibility(theme,'1.1.0')['valid']
def test_site_build_contains_commercial_css_artwork_and_report(factory):
sid=site(factory)
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'industrial','version':'1.1.0'}).status_code==200
result=factory.post(f'/api/sites/{sid}/builds',json={})
assert result.status_code==200,result.text
bid=result.json()['version']
url=f'/preview/{sid}/{bid}'
home=factory.get(url+'/').text
soup=BeautifulSoup(home,'html.parser')
assert soup.select_one('link[href="assets/commercial.css"]')
assert len(soup.select('h1'))==1
assert soup.select_one('meta[property="og:title"]')
assert factory.get(url+'/assets/commercial.css').status_code==200
svg=factory.get(url+'/assets/visual/hero.svg')
assert svg.status_code==200 and '<svg' in svg.text
report=factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
assert not report['errors']
assert report['commercial_readiness']['commercially_certified'] is False
handoff=factory.get(url+'/commercial-readiness.json')
assert handoff.status_code==200
obj=handoff.json();assert obj['commercially_certified'] is False
assert any(w['code']=='brand_logo' for w in obj['warnings'])
def test_theme_rollback_keeps_previous_version(factory):
sid=site(factory)
for version in ('1.0.0','1.1.0'):
selected=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':version})
assert selected.status_code==200,selected.text
result=factory.post(f'/api/sites/{sid}/builds',json={})
assert result.status_code==200,result.text
html=factory.get(f'/preview/{sid}/{result.json()["version"]}/').text
assert ('assets/commercial.css' in html)==(version=='1.1.0')
def test_readiness_detects_missing_required_pages_and_contact(tmp_path):
root=tmp_path
(root/'index.html').write_text('<html><head><title>Test</title></head><body><h1>Test</h1></body></html>')
report=commercial_readiness.inspect(root)
assert not report['ready_for_review']
assert report['commercially_certified'] is False
assert any(x['code']=='page_missing' for x in report['blockers'])
def test_online_theme_preview_loads_same_css_and_hero(factory):
preview=factory.get('/api/factory/themes/automotive/preview',params={'version':'1.1.0','page_type':'home'})
assert preview.status_code==200
assert '/ui/commercial.css' in preview.text
sheet=factory.get('/ui/commercial.css')
assert sheet.status_code==200 and 'pro-filter-panel' in sheet.text
art=factory.get('/api/factory/themes/automotive/visual/hero.svg',params={'version':'1.1.0'})
assert art.status_code==200 and 'image/svg+xml' in art.headers['content-type']
assert factory.get('/api/factory/themes/automotive/visual/hero.svg',params={'version':'1.0.0'}).status_code==404
def test_custom_declarative_theme_inherits_trusted_visuals(factory):
import io,zipfile
from test_factory_v2 import zip_theme
blob=io.BytesIO(zip_theme('art-compatible','2.0.0'))
response=factory.post('/api/factory/themes/install',files={'file':('theme.zip',blob.getvalue(),'application/zip')})
assert response.status_code==200,response.text
folder=theme_store.resolve('art-compatible','2.0.0')
assert (folder/'visual/hero.svg').is_file()
+196
View File
@@ -0,0 +1,196 @@
"""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\nhttps://old.example/,Title from old,Old description,,200\nhttps://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\nhttps://old.example/about/\nhttps://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
assert 'pro.js' in html
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\nhttps://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
+134
View File
@@ -0,0 +1,134 @@
import json
import os
from fastapi.testclient import TestClient
import pytest
from app import db, enterprise, worker
from app.main import app
from app.worker import run_once
@pytest.fixture
def root(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'app.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','administrator-strong-password-2026')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','secure-random-test-key-with-long-length-1234')
monkeypatch.setenv('SITEFORGE_PUBLISH_ROOT',str(tmp_path/'published'))
with TestClient(app) as c:
assert c.post('/api/auth/login',json={'username':'admin','password':'administrator-strong-password-2026'}).status_code==200
csrf=c.get('/api/auth/me').json()['csrf']
c.headers.update({'X-SiteForge-CSRF':csrf})
yield c
def site(c,name):
r=c.post('/api/sites',json={'name':name,'theme':'corporate','mode':'migration','domain':'https://example.org'})
assert r.status_code==201,r.text
return r.json()['id']
def test_team_rbac_isolation(root):
one=site(root,'Company A')
two=site(root,'Company B')
response=root.post('/api/enterprise/team/users',json={'username':'editor1','password':'long-editor-password-2026','display_name':'Editor'})
assert response.status_code==201,response.text
uid=response.json()['id']
r=root.put(f'/api/enterprise/sites/{one}/members',json={'user_id':uid,'role':'editor'})
assert r.status_code==200,r.text
root.post('/api/auth/logout')
assert root.post('/api/auth/login',json={'username':'editor1','password':'long-editor-password-2026'}).status_code==200
root.headers.update({'X-SiteForge-CSRF':root.get('/api/auth/me').json()['csrf']})
visible=root.get('/api/sites').json()
assert [x['id'] for x in visible]==[one]
assert root.get(f'/api/sites/{two}').status_code==403
assert root.post(f'/api/sites/{two}/entries',json={'title':'Blocked'}).status_code==403
assert root.post('/api/sites',json={'name':'Blocked'}).status_code==403
assert root.post(f'/api/sites/{one}/entries',json={'title':'Allowed','type':'page','route':'/allowed/'}).status_code==201
assert root.post(f'/api/sites/{one}/builds/invalid/publish-local').status_code==403
assert root.get('/api/enterprise/team/users').status_code==403
assert root.get(f'/api/enterprise/sites/{one}/baseline/report').status_code==200
def test_baseline_and_release_gate(root):
sid=site(root,'Legacy')
x=root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Original','route':'/legacy/a.html','seo_title':'Old Title'})
assert x.status_code==201
assert root.post(f'/api/enterprise/sites/{sid}/baseline/snapshot').json()['captured']==2
e=x.json()['id']
assert root.delete(f'/api/sites/{sid}/entries/{e}').status_code==200
report=root.get(f'/api/enterprise/sites/{sid}/baseline/report').json()
assert report['errors']==1
build=root.post(f'/api/sites/{sid}/builds',json={})
assert build.status_code==200,build.text
bid=build.json()['version']
publish=root.post(f'/api/sites/{sid}/builds/{bid}/publish-local')
assert publish.status_code==409
assert root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Replaced','route':'/legacy/a.html','seo_title':'Old Title'}).status_code==201
assert root.get(f'/api/enterprise/sites/{sid}/baseline/report').json()['errors']==0
rebuilt=root.post(f'/api/sites/{sid}/builds',json={}).json()['version']
publish=root.post(f'/api/sites/{sid}/builds/{rebuilt}/publish-local')
assert publish.status_code==200,publish.text
qa=root.get(f'/api/enterprise/sites/{sid}/builds/{rebuilt}/quality')
assert qa.status_code==200,qa.text
assert qa.json()['files']>=2
history=root.get(f'/api/enterprise/sites/{sid}/releases').json()
assert history and history[0]['build_id']==rebuilt
rollback=root.post(f'/api/enterprise/sites/{sid}/releases/{rebuilt}/rollback')
assert rollback.status_code==200,rollback.text
def test_revision_restore_and_design_tokens(root):
sid=site(root,'Design')
x=root.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Draft v1','route':'/old-page/'})
entry=x.json()['id']
assert root.patch(f'/api/sites/{sid}/entries/{entry}',json={'title':'Draft v2'}).status_code==200
revs=root.get(f'/api/enterprise/sites/{sid}/entries/{entry}/revisions').json()
assert len(revs)>=1
old=root.get(f'/api/enterprise/sites/{sid}/revisions/{revs[0]["id"]}').json()
assert old['entry']['title']=='Draft v1'
assert root.post(f'/api/enterprise/sites/{sid}/revisions/{revs[0]["id"]}/restore').status_code==200
refreshed=root.get(f'/api/sites/{sid}/entries').json()
assert any(i['title']=='Draft v1' for i in refreshed)
invalid=root.put(f'/api/enterprise/sites/{sid}/design-tokens',json={'font':'system','radius':999})
assert invalid.status_code==400
payload={'font':'serif','heading_scale':1.2,'radius':18,'section_spacing':80,'container_width':1360,'button_style':'pill','header_style':'compact'}
assert root.put(f'/api/enterprise/sites/{sid}/design-tokens',json=payload).status_code==200
assert root.get(f'/api/enterprise/sites/{sid}/design-tokens').json()==payload
build=root.post(f'/api/sites/{sid}/builds',json={}).json()['version']
import pathlib
css=(db.DATA/'builds'/sid/build/'dist'/'assets'/'custom.css').read_text(encoding='utf-8')
assert 'Georgia' in css and '--sf-width:1360px' in css
def test_background_worker_and_audit(root):
sid=site(root,'Jobs')
res=root.post(f'/api/enterprise/sites/{sid}/jobs',json={'kind':'build'})
assert res.status_code==202,res.text
assert run_once()==res.json()['id']
jobs=root.get(f'/api/enterprise/sites/{sid}/jobs').json()
assert jobs[0]['state']=='done'
audits=root.get('/api/enterprise/audit').json()
assert any(x['operation']=='POST' for x in audits)
assert root.get('/ui/enterprise-ui.js').status_code==200
def test_background_worker_retries_transient_failure(root,monkeypatch):
sid=site(root,'Retry Jobs')
job=root.post(f'/api/enterprise/sites/{sid}/jobs',json={'kind':'build'}).json()['id']
monkeypatch.setenv('SITEFORGE_JOB_MAX_ATTEMPTS','3')
monkeypatch.setattr(worker.builder,'build',lambda *_: (_ for _ in ()).throw(RuntimeError('temporary failure')))
assert worker.run_once()==job
queued=db.one('SELECT state,attempts,error,retry_at FROM build_jobs WHERE id=?',(job,))
assert queued['state']=='queued' and queued['attempts']==1
assert queued['retry_at'] and 'temporary failure' in queued['error']
def test_viewer_cannot_modify_or_see_other_site(root):
own=site(root,'Site One'); foreign=site(root,'Site Two')
user=root.post('/api/enterprise/team/users',json={'username':'viewer22','display_name':'Viewer','password':'long-viewer-password-2026'}).json()['id']
assert root.put(f'/api/enterprise/sites/{own}/members',json={'user_id':user,'role':'viewer'}).status_code==200
root.post('/api/auth/logout')
root.post('/api/auth/login',json={'username':'viewer22','password':'long-viewer-password-2026'})
root.headers['X-SiteForge-CSRF']=root.get('/api/auth/me').json()['csrf']
assert root.get(f'/api/sites/{own}/entries').status_code==200
assert root.get(f'/preview/{foreign}/any/').status_code==403
assert root.post(f'/api/sites/{own}/builds',json={}).status_code==403
assert root.put(f'/api/enterprise/sites/{own}/design-tokens',json={}).status_code==403
assert root.get(f'/api/enterprise/sites/{own}/jobs').status_code==200
assert root.get(f'/api/enterprise/sites/{foreign}/jobs').status_code==403
+269
View File
@@ -0,0 +1,269 @@
import io
import json
import zipfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app import db
from app.main import app
from app.core import route_path,ensure_public_url
@pytest.fixture
def client(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'test.sqlite3')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','strong-supervisor-password-2026')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','large-random-key-for-test-only-2026-the-test-environment')
with TestClient(app) as c:
assert c.get('/api/sites').status_code == 401
assert c.post('/api/auth/login',json={'password':'strong-supervisor-password-2026'}).status_code == 200
auth=c.get('/api/auth/me').json()
c.headers.update({'X-SiteForge-CSRF': auth['csrf']})
yield c
def create(c,theme='automotive'):
r=c.post('/api/sites',json={'name':'Example Test','theme':theme,'domain':'https://example.test','mode':'migration'})
assert r.status_code==201,r.text
return r.json()['id']
def test_site_and_theme_switch_preserve_content(client):
a=create(client)
b=create(client,'industrial')
payload={'type':'product','title':'Product A','route':'/products/legacy.html','seo_title':'Original SEO Title','seo_description':'Original SEO Description','body':'<p>Hello <strong>world</strong></p>'}
x=client.post(f'/api/sites/{a}/entries',json=payload)
assert x.status_code==201,x.text
assert len(client.get(f'/api/sites/{b}/entries').json())==1
for theme in ('automotive','industrial','corporate','minimal'):
assert client.patch(f'/api/sites/{a}',json={'theme':theme}).status_code==200
info=client.post(f'/api/sites/{a}/builds',json={'mirror_images':False})
assert info.status_code==200,info.text
bid=info.json()['version']
resp=client.get(f'/preview/{a}/{bid}/products/legacy.html')
assert resp.status_code==200,resp.text[:150]
assert '<title>Original SEO Title</title>' in resp.text
assert '<link rel="canonical" href="https://example.test/products/legacy.html">' in resp.text
assert 'Original SEO Description' in resp.text
assert 'Hello' in resp.text
dl=client.get(f'/api/sites/{a}/builds/{bid}/download')
with zipfile.ZipFile(io.BytesIO(dl.content)) as z:
assert 'products/legacy.html' in z.namelist()
assert 'assets/theme.css' in z.namelist()
assert 'sitemap.xml' in z.namelist()
assert len(client.get(f'/api/sites/{a}/entries').json())==2
def test_home_blueprint_and_static_links(client):
sid=create(client)
pages=client.get(f'/api/sites/{sid}/entries').json()
home=pages[0]
assert home['route']=='/'
meta={'blocks':[{'type':'features','title':'优势清单','items':[{'title':'服务能力','text':'广泛覆盖'}]},
{'type':'cta','title':'马上合作','href':'/contact/'}]}
assert client.patch(f'/api/sites/{sid}/entries/{home["id"]}',json={'meta':meta}).status_code==200
r=client.post(f'/api/sites/{sid}/builds',json={})
bid=r.json()['version']
txt=client.get(f'/preview/{sid}/{bid}/').text
assert '优势清单' in txt and '马上合作' in txt
def test_duplicate_routes_and_unsafe_html(client):
sid=create(client)
x=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'X','route':'/test/','body':'<script>alert(1)</script><a href="javascript:alert(1)">x</a>'})
assert x.status_code==201
y=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Y','route':'/test/'})
assert y.status_code==400
z=client.get(f'/api/sites/{sid}/entries').json()
it=next(x for x in z if x['route']=='/test/')
assert '<script>' not in it['body']
assert 'href="javascript:' not in it['body']
assert client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'unsafe','route':'/x/../y/'}).status_code==400
def test_data_import_and_presets(client):
sid=create(client)
csv_data=b'type,title,route,seo_title\nproduct,Vehicle 1,/cars/vehicle-1/,Vehicle One SEO\nproduct,Vehicle 2,/cars/vehicle-2/,Vehicle Two SEO\n'
response=client.post(f'/api/sites/{sid}/import',data={'on_conflict':'skip'},files={'file':('cars.csv',csv_data,'text/csv')})
assert response.status_code==200,response.text
assert response.json()['imported']==2
r2=client.post(f'/api/sites/{sid}/import',data={'on_conflict':'skip'},files={'file':('cars.csv',csv_data,'text/csv')})
assert r2.json()['skipped']==2
save=client.post(f'/api/sites/{sid}/save-preset',json={'name':'My Master Theme'})
assert save.status_code==200
reuse=client.post('/api/sites',json={'name':'New from Preset','preset_id':save.json()['id']})
assert reuse.status_code==201
assert reuse.json()['theme']=='automotive'
def test_wxr_legacy_urls_meta(client):
sid=create(client)
wxr=b'''<?xml version="1.0" encoding="utf-8"?><rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wp="http://wordpress.org/export/1.2/" version="2.0"><channel><item>
<title>News</title><link>https://old.example.org/news/article-01.html</link>
<wp:post_type>post</wp:post_type><wp:status>publish</wp:status><wp:post_name>article-01</wp:post_name>
<content:encoded><![CDATA[<h2>Article</h2><p>WordPress body</p>]]></content:encoded>
<wp:postmeta><wp:meta_key>_yoast_wpseo_title</wp:meta_key><wp:meta_value>Exact SEO Title</wp:meta_value></wp:postmeta>
</item></channel></rss>'''
response=client.post(f'/api/sites/{sid}/import',files={'file':('wordpress.xml',wxr,'application/xml')},data={'on_conflict':'skip'})
assert response.status_code==200,response.text
arr=client.get(f'/api/sites/{sid}/entries').json()
match=next(p for p in arr if p['title']=='News')
assert match['route']=='/news/article-01.html'
assert match['seo_title']=='Exact SEO Title'
assert 'WordPress body' in match['body']
def test_media_archive(client):
sid=create(client)
buffer=io.BytesIO()
with zipfile.ZipFile(buffer,'w') as zipf:
zipf.writestr('wp-content/uploads/2024/01/product.jpg',b'\xff\xd8\xff\xd9')
zipf.writestr('../bad.php',b'evil')
buffer.seek(0)
r=client.post(f'/api/sites/{sid}/media-archive',files={'file':('uploads.zip',buffer.getvalue(),'application/zip')})
assert r.status_code==200,r.text
assert r.json()['imported']==1
built=client.post(f'/api/sites/{sid}/builds',json={})
assert built.status_code==200
z=client.get(f'/api/sites/{sid}/builds/{built.json()["version"]}/download')
with zipfile.ZipFile(io.BytesIO(z.content)) as archive:
assert 'wp-content/uploads/2024/01/product.jpg' in archive.namelist()
assert not any('bad.php' in s for s in archive.namelist())
def test_redirect_and_ssrf_reject(client):
sid=create(client)
assert client.post(f'/api/sites/{sid}/redirects',json={'old_path':'/old.html','new_path':'/new/','status_code':301}).status_code==200
r=client.post(f'/api/sites/{sid}/builds',json={})
assert r.status_code==200
assert len(client.get(f'/api/sites/{sid}/redirects').json())==1
bad=client.post(f'/api/sites/{sid}/crawl',json={'url':'http://127.0.0.1:8787/'})
assert bad.status_code==400
bad=client.post(f'/api/sites/{sid}/crawl',json={'url':'file:///etc/passwd'})
assert bad.status_code==400
def test_login_and_csrf_enforcement(client):
session=client.cookies.get('siteforge_session')
assert session
me=client.get('/api/auth/me')
assert me.json()['version']=='5.0.0'
assert me.headers['x-frame-options']=='SAMEORIGIN'
assert "frame-ancestors 'self'" in me.headers['content-security-policy']
assert 'camera=()' in me.headers['permissions-policy']
original=client.headers.pop('X-SiteForge-CSRF')
assert client.post('/api/sites',json={'name':'Blocked'}).status_code == 403
client.headers['X-SiteForge-CSRF']=original
assert client.get('/api/sites').status_code == 200
assert client.post('/api/auth/logout').status_code == 200
assert client.get('/api/sites').status_code == 401
assert client.get('/').status_code in (200, 303) # TestClient follows /login
assert '管理员密码' in client.get('/login').text
def test_health_uses_current_application_version(client):
client.cookies.clear()
assert client.get('/api/health').json()=={'status':'ok','name':'SiteForge','version':'5.0.0'}
def test_build_download_requires_login(client):
sid=create(client)
result=client.post(f'/api/sites/{sid}/builds',json={}).json()
bid=result['version']
ok=client.get(f'/api/sites/{sid}/builds/{bid}/download')
assert ok.status_code==200
client.cookies.clear()
assert client.get(f'/api/sites/{sid}/builds/{bid}/download').status_code==401
assert '/login' in client.get(f'/preview/{sid}/{bid}/').url.path
def test_visual_blocks_auto_bind_save_build_and_site_isolation(client):
sid=create(client,'corporate')
other=create(client,'minimal')
prod=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'CUSTOM SUV 01','route':'/autos/custom-01/','summary':'Quality example SUV'}).json()
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
blocks=[
{'id':'hero-block-1','type':'hero','title':'{{site.name}}','subtitle':'WELCOME','description':'{{site.tagline}}','button':'汽车列表','href':'/product/'},
{'id':'products-1','type':'collection','title':'车型推荐','source':'product','limit':6,'columns':3},
{'id':'stats-block-1','type':'stats','title':'统计数据','items':[{'value':'3,000+','label':'全球交付'}]}
]
save=client.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':blocks,'page_template':'home'})
assert save.status_code==200,save.text
preview=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
assert preview.status_code==200
assert 'CUSTOM SUV 01' in preview.text and '车型推荐' in preview.text
assert 'AUTOS' not in preview.text
resp=client.post(f'/api/sites/{sid}/builds',json={})
assert resp.status_code==200,resp.text
assert resp.json()['blueprint_blocks']>=3
html=client.get(f'/preview/{sid}/{resp.json()["version"]}/').text
assert 'CUSTOM SUV 01' in html and '车型推荐' in html
assert 'assets/blocks.css' in html
assert client.get(f'/api/sites/{other}/pages/{homepage["id"]}/blueprint').status_code==404
assert client.get(f'/editor/{sid}/{homepage["id"]}').status_code==200
def test_schema_navigation_and_preset(client):
sid=create(client)
fields=[{'name':'model_code','label':'产品编号','type':'text'},{'name':'horsepower','label':'马力','type':'number'}]
r=client.put(f'/api/sites/{sid}/schemas/product',json=fields)
assert r.status_code==200,r.text
assert client.get(f'/api/sites/{sid}/schemas').json()['product']==fields
assert client.put(f'/api/sites/{sid}/schemas/product',json=[{'name':'../not_safe','type':'text'}]).status_code==400
nav=[{'title':'首页','route':'/'},{'title':'车型','route':'/product/'}]
assert client.put(f'/api/sites/{sid}/navigation',json=nav).status_code==200
assert client.get(f'/api/sites/{sid}').json()['settings']['nav']==nav
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
r=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint/preset',json={'page_template':'about'})
assert r.status_code==200
assert len(r.json()['blocks'])==3
assert client.get('/api/themes').json()[0]['page_templates']
def test_blocks_validation_escaping_and_disabled(client):
sid=create(client)
homepage=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
blocks=[{'id':'dangerous-x','type':'hero','title':'<img src=x onerror=alert(1)>','image':'javascript:alert(1)','href':'javascript:alert(1)'},
{'id':'hidden-one','type':'richtext','title':'should be hidden','body':'<script>alert(1)</script>','visible':False}]
res=client.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
assert res.status_code==200,res.text
assert 'onerror=alert' in res.text # text escaped, never a literal img tag
assert '&lt;img src=x onerror' in res.text
assert 'href="/"' in res.text or 'href="./"' in res.text
assert 'should be hidden' not in res.text
assert client.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':[{'id':'bad-id','type':'script','title':'Bad'}],'page_template':'home'}).status_code==400
def test_bulk_theme_assembly_preserves_legacy_url_and_seo(client):
sid=create(client,'industrial')
resp=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'HDPE Pipe','route':'/old-product/hdpe-pipe.html','body':'<h2>Legacy Technical Details</h2>','seo_title':'HDPE pipe SEO forever','seo_description':'Exact old metadata'})
assert resp.status_code==201
entry=resp.json()
assert not entry['meta'].get('blocks')
done=client.post(f'/api/sites/{sid}/apply-theme',json={'mode':'missing'})
assert done.status_code==200,done.text
assert done.json()['updated']>=1
updated=client.get(f'/api/sites/{sid}/entries').json()
final=next(x for x in updated if x['id']==entry['id'])
assert final['route']=='/old-product/hdpe-pipe.html'
assert final['seo_title']=='HDPE pipe SEO forever'
assert len(final['meta']['blocks'])>=2
built=client.post(f'/api/sites/{sid}/builds',json={})
assert built.status_code==200,built.text
html=client.get(f'/preview/{sid}/{built.json()["version"]}/old-product/hdpe-pipe.html').text
assert 'Legacy Technical Details' in html
assert '<title>HDPE pipe SEO forever</title>' in html
def test_scaffold_standard_pages_safe_idempotent(client):
sid=create(client)
first=client.post(f'/api/sites/{sid}/scaffold')
assert first.status_code==200
assert '/about/' in first.json()['created']
second=client.post(f'/api/sites/{sid}/scaffold')
assert second.status_code==200 and not second.json()['created']
lst=client.get(f'/api/sites/{sid}/entries').json()
assert len([x for x in lst if x['route']=='/about/'])==1
build=client.post(f'/api/sites/{sid}/builds',json={})
assert build.status_code==200,build.text
def test_attributes_bind_imported_and_typed_fields(client):
sid=create(client,'automotive')
product=client.post(f'/api/sites/{sid}/entries',json={'type':'product','title':'BYD SUV','route':'/product/byd-suv/','meta':{'fields':{'brand':'BYD','model':'SUV 1','specs':'Electric'}}})
assert product.status_code==201
eid=product.json()['id']
blocks=[{'id':'attributes-1','type':'attributes','title':'Technical Parameters'}]
save=client.put(f'/api/sites/{sid}/pages/{eid}/blueprint',json={'blocks':blocks,'page_template':'product'})
assert save.status_code==200,save.text
b=client.post(f'/api/sites/{sid}/builds',json={})
assert b.status_code==200,b.text
page=client.get(f'/preview/{sid}/{b.json()["version"]}/product/byd-suv/').text
assert 'Technical Parameters' in page and '>BYD<' in page and 'SUV 1' in page
+160
View File
@@ -0,0 +1,160 @@
import io,json,zipfile
import pytest
from fastapi.testclient import TestClient
from app import db,theme_store
from app.main import app
@pytest.fixture
def factory(tmp_path,monkeypatch):
monkeypatch.setattr(db,'DATA',tmp_path)
monkeypatch.setattr(db,'DB_FILE',tmp_path/'site.sqlite3')
monkeypatch.setattr(theme_store,'THEME_ROOT',tmp_path/'theme_packages')
monkeypatch.setenv('SITEFORGE_ADMIN_PASSWORD','strong-admin-2026-secret')
monkeypatch.setenv('SITEFORGE_SESSION_SECRET','longer-test-session-secret-2026-not-for-production')
with TestClient(app) as c:
r=c.post('/api/auth/login',json={'username':'admin','password':'strong-admin-2026-secret'})
assert r.status_code==200,r.text
c.headers.update({'X-SiteForge-CSRF':c.get('/api/auth/me').json()['csrf']})
yield c
def site(c,mode='new'):
r=c.post('/api/sites',json={'name':'Factory Sandbox','mode':mode,'theme':'automotive','domain':'https://test.example'})
assert r.status_code==201,r.text
return r.json()['id']
def zip_theme(id='brand-x',version='1.0.0',bad=False):
from pathlib import Path
root=Path(__file__).resolve().parent.parent/'themes'/'corporate'
arr=io.BytesIO()
with zipfile.ZipFile(arr,'w') as z:
data=json.loads((root/'manifest.json').read_text(encoding='utf-8'));data.update({'id':id,'version':version,'name':'V2 Test Brand'})
z.writestr('manifest.json',json.dumps(data))
z.writestr('theme.css','body{background:#f5f5f5}')
z.writestr('page-presets.json',(root/'page-presets.json').read_bytes())
if bad:z.writestr('../hack.py','print(1)')
return arr.getvalue()
def test_install_theme_and_pinned_build(factory):
sid=site(factory)
r=factory.post('/api/factory/themes/install',files={'file':('theme.zip',zip_theme(),'application/zip')})
assert r.status_code==200,r.text
assert r.json()['id']=='brand-x'
assert 'brand-x' in [x.get('id',x.get('theme_id')) for x in factory.get('/api/factory/themes').json()]
update=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'brand-x','version':'1.0.0'})
assert update.status_code==200,update.text
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
b=factory.post(f'/api/sites/{sid}/builds',json={})
assert b.status_code==200,b.text
info=factory.get(f'/api/sites/{sid}/builds/{b.json()["version"]}/report').json()
assert info['theme']=='brand-x' and info['theme_version']=='1.0.0'
file=factory.get(f'/preview/{sid}/{b.json()["version"]}/search/').text
assert '站内搜索' in file
assert factory.get(f'/api/factory/sites/{sid}/theme').json()['version']=='1.0.0'
def test_theme_rejects_zip_traversal(factory):
r=factory.post('/api/factory/themes/install',files={'file':('evil.zip',zip_theme(bad=True),'application/zip')})
assert r.status_code==400
def test_complete_bootstrap_repeated_safe(factory):
sid=site(factory)
r=factory.post(f'/api/factory/sites/{sid}/bootstrap').json()
assert len(r['created'])>=7 and '/about/' in r['created']
r2=factory.post(f'/api/factory/sites/{sid}/bootstrap').json()
assert not r2['created']
records=factory.get(f'/api/sites/{sid}/entries').json()
assert all(r['route'] for r in records)
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
html=factory.get(f'/preview/{sid}/{build["version"]}/').text
assert 'sf-site-head' in html and 'factory.css' in html
assert factory.get(f'/preview/{sid}/{build["version"]}/assets/site-runtime.js').status_code==200
def test_mapping_preview_commit_and_cross_site(factory):
a=site(factory);b=site(factory)
rows=[{'车型名称':'A SUV','品牌':'Alpha','原网址':'/legacy/suv-a.html','续航':'400km'},
{'车型名称':'B SUV','品牌':'Beta','原网址':'/legacy/suv-b.html','续航':'500km'}]
rules={'title':'车型名称','brand':'品牌','route':'原网址','range':'续航'}
mapping=factory.post(f'/api/factory/sites/{a}/mapping/run',json={'rows':rows,'rules':rules,'content_type':'vehicle','dry_run':True})
assert mapping.status_code==200,mapping.text
assert mapping.json()['mapped']==2
assert factory.get(f'/api/sites/{b}/entries').json()[0]['route']=='/'
saved=factory.post(f'/api/factory/sites/{a}/mapping/run',json={'rows':rows,'rules':rules,'content_type':'vehicle','dry_run':False})
assert saved.status_code==200,saved.text
assert saved.json()['created']==2
imported=factory.get(f'/api/sites/{a}/entries').json()
suv=next(r for r in imported if r['title']=='A SUV')
assert suv['route']=='/legacy/suv-a.html' and suv['meta']['fields']['brand']=='Alpha'
assert all(r['title']!='A SUV' for r in factory.get(f'/api/sites/{b}/entries').json())
again=factory.post(f'/api/factory/sites/{a}/mapping/run',json={'rows':rows,'rules':rules,'content_type':'vehicle','dry_run':False})
assert again.json()['skipped']==2
def test_migration_readiness_wont_greenwash(factory):
sid=site(factory,mode='migration')
r=factory.get(f'/api/factory/sites/{sid}/migration/readiness').json()
assert not r['can_publish']
assert any('基线' in x['message'] for x in r['gates'])
assert any('构建' in x['message'] for x in r['gates'])
assert factory.post(f'/api/factory/sites/{sid}/mapping/run',json={'rows':[{'name':''}],'rules':{'title':'name'},'dry_run':True}).json()['errors']
def test_editor_supports_responsive_settings(factory):
sid=site(factory)
homepage=next(x for x in factory.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
blocks=homepage['meta']['blocks'];blocks[0]['style']={'mobile_padding':19,'tablet_padding':34,'hide_mobile':True}
r=factory.put(f'/api/sites/{sid}/pages/{homepage["id"]}/blueprint',json={'blocks':blocks,'page_template':'home'})
assert r.status_code==200,r.text
preview=factory.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
assert preview.status_code==200
assert 'siteforge-inline-editor' in preview.text and '--sf-pad:19px' in preview.text
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
css=factory.get(f'/preview/{sid}/{build["version"]}/assets/custom.css').text
assert '--sf-pad:19px' in css and 'display:none' in css
def test_release_requires_separate_approval(factory,monkeypatch):
sid=site(factory)
factory.post(f'/api/factory/sites/{sid}/bootstrap')
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
bid=build['version']
monkeypatch.setenv('SITEFORGE_REQUIRE_RELEASE_APPROVAL','true')
monkeypatch.setenv('SITEFORGE_PUBLISH_ROOT',str(db.DATA/'published'))
fail=factory.post(f'/api/sites/{sid}/builds/{bid}/publish-local')
assert fail.status_code==409,fail.text
req=factory.post(f'/api/factory/sites/{sid}/release-requests',json={'build_id':bid})
# If QA reports a real error we must not silently approve the release.
if req.status_code==409:
from app import quality
issues=quality.report(sid,bid)
assert issues['errors']>0,issues
return
assert req.status_code==200,req.text
rid=req.json()['id']
same_user=factory.post(f'/api/factory/sites/{sid}/release-requests/{rid}/review',json={'state':'approved'})
assert same_user.status_code==403
user=factory.post('/api/enterprise/team/users',json={'username':'review_v2','password':'very-long-review-password-2026','display_name':'Reviewer'}).json()
factory.put(f'/api/enterprise/sites/{sid}/members',json={'user_id':user['id'],'role':'reviewer'})
factory.post('/api/auth/logout')
assert factory.post('/api/auth/login',json={'username':'review_v2','password':'very-long-review-password-2026'}).status_code==200
factory.headers.update({'X-SiteForge-CSRF':factory.get('/api/auth/me').json()['csrf']})
approved=factory.post(f'/api/factory/sites/{sid}/release-requests/{rid}/review',json={'state':'approved'})
assert approved.status_code==200,approved.text
# Reviewer cannot run owner's publish action.
assert factory.post(f'/api/sites/{sid}/builds/{bid}/publish-local').status_code==403
factory.post('/api/auth/logout')
factory.post('/api/auth/login',json={'password':'strong-admin-2026-secret'})
factory.headers.update({'X-SiteForge-CSRF':factory.get('/api/auth/me').json()['csrf']})
assert factory.post(f'/api/sites/{sid}/builds/{bid}/publish-local').status_code==200
def test_theme_previews_are_rendered_by_trusted_templates(factory):
for name in ('automotive','industrial','corporate','minimal'):
result=factory.get(f'/api/factory/themes/{name}/preview')
assert result.status_code==200,result.text[:500]
assert 'sf-site-head' in result.text
assert 'assets/theme.css' not in result.text
assert 'sf-canvas' in result.text
assert factory.get(f'/api/factory/themes/{name}/preview.css').status_code==200
def test_theme_page_type_previews(factory):
for theme in ('automotive','industrial','corporate','minimal'):
for page_type in ('home','about','product','article','brand','listing','contact','standard','notfound'):
p=factory.get(f'/api/factory/themes/{theme}/preview',params={'page_type':page_type})
assert p.status_code==200,(theme,page_type,p.text[:300])
assert 'text/html' in p.headers['content-type']
assert factory.get('/api/factory/themes/automotive/preview?page_type=wrong').status_code==400
+122
View File
@@ -0,0 +1,122 @@
"""Professional theme system integration tests use the real FastAPI and static builder."""
import io
import json
import re
import zipfile
from pathlib import Path
from bs4 import BeautifulSoup
from test_factory_v2 import factory, site, zip_theme
THEMES=('automotive','industrial','corporate','minimal')
def test_four_complete_theme_families_have_distinct_markup(factory):
main=[]
for theme in THEMES:
for page_type in ('home','about','contact','listing','product','article','brand','standard','notfound'):
r=factory.get(f'/api/factory/themes/{theme}/preview',params={'page_type':page_type})
assert r.status_code==200,(theme,page_type,r.text[:400])
soup=BeautifulSoup(r.text,'html.parser')
assert soup.find('header',class_='pro-header')
assert soup.find('footer',class_='pro-footer')
assert len(soup.find_all('h1'))==1,(theme,page_type,len(soup.find_all('h1')))
assert 'pro-base.css' in r.text and 'pro.js' in r.text
if page_type=='product':assert soup.select_one('.pro-product-layout .pro-specs')
if page_type=='listing':assert soup.select_one('[data-pro-filter-search]')
if page_type=='article':assert soup.select_one('.pro-article-hero')
main.append(factory.get(f'/api/factory/themes/{theme}/preview?page_type=home').text)
assert len(set(re.findall(r'class="pro-home-hero [^"]+"',s)[0] for s in main))==4
def test_theme_build_derived_category_brand_search_and_seo(factory):
sid=site(factory)
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
r=factory.post(f'/api/sites/{sid}/entries',json={
'type':'product','title':'Electric A','route':'/old/product-a.html',
'summary':'Long-range sample','seo_title':'Keep Me SEO','seo_description':'Original description',
'meta':{'fields':{'brand':'Quanta Motors','category':'SUV','range':'500 km'}}})
assert r.status_code==201,r.text
for theme in THEMES:
selected=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':theme,'version':'1.0.0'})
assert selected.status_code==200,selected.text
result=factory.post(f'/api/sites/{sid}/builds',json={})
assert result.status_code==200,result.text
bid=result.json()['version']
for url in ['/','/about/','/contact/','/products/','/brands/','/news/','/cases/','/categories/suv/','/brands/quanta-motors/','/search/','/old/product-a.html']:
page=factory.get(f'/preview/{sid}/{bid}{url}')
assert page.status_code==200,(theme,url,page.text[:200])
soup=BeautifulSoup(page.text,'html.parser')
assert len(soup.find_all('h1'))==1,(theme,url)
assert soup.select_one('.pro-header') and soup.select_one('.pro-footer')
product=factory.get(f'/preview/{sid}/{bid}/old/product-a.html').text
assert '<title>Keep Me SEO</title>' in product
assert 'range' in product.lower() and '500 km' in product
assert 'https://test.example/old/product-a.html' in product
listing=factory.get(f'/preview/{sid}/{bid}/categories/suv/').text
assert 'Electric A' in listing
theme_css=factory.get(f'/preview/{sid}/{bid}/assets/pro.css')
assert theme_css.status_code==200 and len(theme_css.content)>2000
report=factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
assert not report['errors'] and not report['broken_links'] and not report['missing_local_images']
def test_builtin_version_pin_is_real(factory):
sid=site(factory)
versions=factory.get('/api/factory/themes/automotive/versions').json()
assert {v['version'] for v in versions}=={'1.1.0','1.0.0','0.3.0'}
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':'0.3.0'}).status_code==200
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
old=factory.get(f'/preview/{sid}/{b["version"]}/').text
assert 'pro-auto-hero' not in old
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':'1.0.0'}).status_code==200
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
new=factory.get(f'/preview/{sid}/{b["version"]}/').text
assert 'pro-auto-hero' in new
def test_custom_theme_package_uses_trusted_layout_and_css(factory):
sid=site(factory)
raw=io.BytesIO(zip_theme('custom-v3','2.0.0'))
data=io.BytesIO()
with zipfile.ZipFile(raw) as old,zipfile.ZipFile(data,'w') as new:
for fn in old.namelist():
content=old.read(fn)
if fn=='manifest.json':
obj=json.loads(content);obj['layout_family']='industrial'
content=json.dumps(obj).encode()
if fn=='theme.css':content=b':root{--pro-accent:#dd5500} .pro-card{border-width:2px}'
new.writestr(fn,content)
install=factory.post('/api/factory/themes/install',files={'file':('theme.zip',data.getvalue(),'application/zip')})
assert install.status_code==200,install.text
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'custom-v3','version':'2.0.0'}).status_code==200
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
assert 'pro-industrial-hero' in factory.get(f'/preview/{sid}/{b["version"]}/').text
css=factory.get(f'/preview/{sid}/{b["version"]}/assets/theme-overrides.css').text
assert '--pro-accent:#dd5500' in css
pkg=factory.get('/api/factory/themes/custom-v3/package?version=2.0.0')
assert pkg.status_code==200
with zipfile.ZipFile(io.BytesIO(pkg.content)) as z:
assert {'manifest.json','theme.css','page-presets.json'}<=set(z.namelist())
def test_static_responsive_and_search_facilities(factory):
sid=site(factory)
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
bid=b['version']
css=factory.get(f'/preview/{sid}/{bid}/assets/pro-base.css').text
js=factory.get(f'/preview/{sid}/{bid}/assets/pro.js').text
assert '@media(max-width:560px)' in css
assert 'aria-expanded' in js and 'data-pro-filter-brand' in js
assert 'search-index.json' in factory.get(f'/preview/{sid}/{bid}/assets/site-runtime.js').text
assert 'noindex' in factory.get(f'/preview/{sid}/{bid}/search/').text
def test_all_fifty_six_page_templates_and_live_gallery(factory):
base=Path(__file__).resolve().parent.parent/'themes'
expected={'home','about','contact','listing','category','product','brand','article','case','service','faq','standard','search','notfound'}
for theme in THEMES:
assert {p.stem for p in (base/theme/'pages').glob('*.html')}==expected
for page_type in ('case','service','faq','category'):
r=factory.get(f'/api/factory/themes/{theme}/preview?page_type={page_type}')
assert r.status_code==200,(theme,page_type,r.text[:300])
page=factory.get('/theme-gallery')
assert page.status_code==200 and 'iframe' in page.text and 'page_type' in page.text