Files
cms-SiteForge/tests/test_v825_shared_components.py
T
浅念 8884e100f9 upgrade: adopt V8.31.1 (theme 1.3/1.4 redesigns, editor deep-canvas, guided launch, component library)
Overlay 190 upstream items (V8.22→8.31) preserving trade-pro theme,
registry tests, and design docs. Re-applied full patch set on identical
anchors (discovery/env_for/blocks.css/type-mapping/locale-override/
theme_color/frontend entries/test_theme_pro_v3 mods) plus compose pins.

Verified: theme suites 35/35; dual-baseline full run 415 passed vs
pristine 386+29 ours, FAILED-set diff EMPTY (same 10 Windows env fails).
Live: container 8.31.1-dev.1 healthy, hesupipe 192 entries intact,
trade-pro build 203 pages 0 errors.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-26 09:43:17 +08:00

210 lines
9.8 KiB
Python

"""V8.25 opt-in shared component correctness, isolation and draft safety."""
import copy
import json
import pytest
from tests.test_factory import client,create
from app import db,blueprints
def home(c,site):
return next(row for row in c.get(f'/api/sites/{site}/entries').json() if row['route']=='/')
def library(c,site,title='SHARED_OLD'):
block=blueprints.new_block('cta',{'title':title})
r=c.post(f'/api/sites/{site}/components',json={'name':'公共行动区','block':block})
assert r.status_code==200,r.text
return r.json()
def link(c,site,entry,item):
block=copy.deepcopy(item['block'])
block['id']='link_'+entry['id'][:10]
block['shared_ref']={'id':item['id'],'version':item['version'],'hash':item['snapshot_hash']}
url=f'/api/sites/{site}/pages/{entry["id"]}/studio-draft'
draft=c.get(url).json()
r=c.put(url,json={'blocks':[block],'page_template':'standard','layout_mode':'replace','expected_revision':draft['revision']})
assert r.status_code==200,r.text
return url,block
def change(c,site,item,title='SHARED_NEW'):
updated=blueprints.new_block('cta',{'title':title})
r=c.put(f'/api/sites/{site}/components/{item["id"]}',json={
'name':'公共行动区','expected_version':item['version'],'block':updated})
assert r.status_code==200,r.text
return r.json()
def test_linked_usage_and_explicit_sync_never_modifies_publication(client):
site=create(client);entry=home(client,site);original=db.one('SELECT meta_json FROM entries WHERE id=?',(entry['id'],))['meta_json']
item=library(client,site);url,block=link(client,site,entry,item)
v2=change(client,site,item)
base=f'/api/sites/{site}/components/{item["id"]}'
preview=client.get(base+'/usage').json()
assert preview['references'][0]['eligible']
assert preview['references'][0]['block_id']==block['id']
assert preview['references'][0]['current_version']==1
r=client.post(base+'/sync',json={'plan_token':preview['plan_token'],'entry_ids':[entry['id']]})
assert r.status_code==200,r.text
assert r.json()['published_unchanged']
newer=client.get(url).json()
assert newer['revision']==2
assert newer['blocks'][0]['title']=='SHARED_NEW'
assert newer['blocks'][0]['id']==block['id']
assert newer['blocks'][0]['shared_ref']['version']==2
assert db.one('SELECT meta_json FROM entries WHERE id=?',(entry['id'],))['meta_json']==original
assert client.get(base+'/usage').json()['references'][0]['up_to_date']
history=client.get(f'/api/sites/{site}/pages/{entry["id"]}/studio-history').json()['items']
assert len(history)==2
v1=client.get(base+'/versions/1').json();v2view=client.get(base+'/versions/2').json()
assert v1['block']['title']=='SHARED_OLD' and v2view['block']['title']=='SHARED_NEW'
assert v1['snapshot_hash']!=v2['snapshot_hash']
def test_edited_instances_are_not_overwritten(client):
site=create(client);entry=home(client,site);item=library(client,site)
url,block=link(client,site,entry,item)
current=client.get(url).json()
current['blocks'][0]['title']='LOCALLY_CUSTOMIZED'
payload={'blocks':current['blocks'],'page_template':'standard','layout_mode':'replace','expected_revision':1}
assert client.put(url,json=payload).status_code==200
change(client,site,item)
root=f'/api/sites/{site}/components/{item["id"]}'
preview=client.get(root+'/usage').json()
assert preview['references'][0]['local_modified']
assert not preview['references'][0]['eligible']
response=client.post(root+'/sync',json={'plan_token':preview['plan_token'],'entry_ids':[entry['id']]})
assert response.status_code==409
assert client.get(url).json()['blocks'][0]['title']=='LOCALLY_CUSTOMIZED'
def test_stale_preview_and_foreign_site_protected(client):
a=create(client);b=create(client);entry=home(client,a)
item=library(client,a);url,_=link(client,a,entry,item)
change(client,a,item)
src=f'/api/sites/{a}/components/{item["id"]}'
preview=client.get(src+'/usage').json()
assert client.get(f'/api/sites/{b}/components/{item["id"]}/usage').status_code==404
assert client.get(f'/api/sites/{b}/components/{item["id"]}/versions').status_code==404
assert client.post(f'/api/sites/{b}/components/{item["id"]}/sync',json={
'plan_token':preview['plan_token'],'entry_ids':[entry['id']]}).status_code==404
draft=client.get(url).json()
assert client.put(url,json={**{k:draft[k] for k in ('blocks','page_template','layout_mode')},
'expected_revision':draft['revision']}).status_code==200
assert client.post(src+'/sync',json={'plan_token':preview['plan_token'],'entry_ids':[entry['id']]}).status_code==409
def test_unlinked_copies_not_counted_and_deletion_blocked(client):
site=create(client);item=library(client,site);entry=home(client,site)
block=copy.deepcopy(item['block'])
r=client.put(f'/api/sites/{site}/pages/{entry["id"]}/studio-draft',json={
'blocks':[block],'page_template':'standard','expected_revision':0})
assert r.status_code==200
root=f'/api/sites/{site}/components/{item["id"]}'
assert client.get(root+'/usage').json()['references']==[]
link(client,site,entry,item)
assert client.delete(root).status_code==409
other=library(client,site,'OTHER')
assert client.delete(f'/api/sites/{site}/components/{other["id"]}').status_code==200
def test_invalid_component_ref_rejected(client):
site=create(client);entry=home(client,site)
item=blueprints.new_block('cta')
item['shared_ref']={'id':'<script>','version':1,'hash':'0'*64}
r=client.put(f'/api/sites/{site}/pages/{entry["id"]}/studio-draft',json={
'blocks':[item],'page_template':'standard','expected_revision':0})
assert r.status_code==400
def test_multiple_page_sync_rolls_back_on_conflict(client):
site=create(client);item=library(client,site)
one=home(client,site)
second=client.post(f'/api/sites/{site}/entries',json={'type':'page','title':'Other','route':'/second/'}).json()
assert second['id']
first_url,_=link(client,site,one,item);second_url,_=link(client,site,second,item)
change(client,site,item)
base=f'/api/sites/{site}/components/{item["id"]}'
usage=client.get(base+'/usage').json()
assert len(usage['references'])==2
err=client.post(base+'/sync',json={'entry_ids':[one['id'],second['id']],'plan_token':'f'*64})
assert err.status_code==409
assert client.get(first_url).json()['revision']==1
result=client.post(base+'/sync',json={'entry_ids':[one['id'],second['id']],
'plan_token':usage['plan_token']})
assert result.status_code==200,result.text
assert len(result.json()['updated'])==2
assert client.get(first_url).json()['revision']==2
assert client.get(second_url).json()['revision']==2
def test_two_page_sync_is_a_single_transaction(client,monkeypatch):
from fastapi import HTTPException
from app import reusables
site=create(client);item=library(client,site)
a=home(client,site)
b=client.post(f'/api/sites/{site}/entries',json={'type':'page','title':'Second','route':'/multi/'}).json()
a_url,_=link(client,site,a,item)
b_url,_=link(client,site,b,item)
change(client,site,item)
base=f'/api/sites/{site}/components/{item["id"]}'
usage=client.get(base+'/usage').json()
original=reusables._copy_into
called=[0]
def break_on_second(*args,**kwargs):
called[0]+=1
if called[0]==2:raise HTTPException(409,'模拟第二页失败')
return original(*args,**kwargs)
monkeypatch.setattr(reusables,'_copy_into',break_on_second)
response=client.post(base+'/sync',json={
'entry_ids':[a['id'],b['id']],'plan_token':usage['plan_token']})
assert response.status_code==409
assert called[0]==2
assert client.get(a_url).json()['revision']==1
assert client.get(b_url).json()['revision']==1
def test_published_link_is_not_republished_by_library_sync(client):
site=create(client);page=home(client,site);item=library(client,site)
url,_=link(client,site,page,item)
assert client.post(url+'/publish',json={'expected_revision':1}).status_code==200
before=db.one('SELECT meta_json FROM entries WHERE id=?',(page['id'],))['meta_json']
assert 'shared_ref' in before
change(client,site,item)
base=f'/api/sites/{site}/components/{item["id"]}'
usage=client.get(base+'/usage').json()
assert usage['references'][0]['eligible'] and not usage['references'][0]['has_draft']
response=client.post(base+'/sync',json={'plan_token':usage['plan_token'],'entry_ids':[page['id']]})
assert response.status_code==200,response.text
assert db.one('SELECT meta_json FROM entries WHERE id=?',(page['id'],))['meta_json']==before
assert client.get(url).json()['state']=='draft'
def test_repeated_migration_and_recursive_detach(client):
from app import reusables
site=create(client)
other=library(client,site)
layout=blueprints.new_block('layout')
nested=blueprints.new_block('cta')
nested['shared_ref']={'id':other['id'],'version':1,'hash':other['snapshot_hash']}
layout['columns'][0]['blocks']=[nested]
saved=client.post(f'/api/sites/{site}/components',json={'name':'新布局','block':layout})
assert saved.status_code==200,saved.text
assert 'shared_ref' not in saved.json()['block']['columns'][0]['blocks'][0]
reusables.setup();reusables.setup()
history=client.get(f'/api/sites/{site}/components/{saved.json()["id"]}/versions').json()
assert len(history['items'])==1
def test_shared_editor_has_clear_actions():
from pathlib import Path
root=Path(__file__).resolve().parents[1]
html=(root/'app/templates/editor.html').read_text()
js=(root/'app/static/editor.js').read_text()
assert 'sharedComponentModal' in html
for method in ('sharedUsage','sharedApply','sharedVersions','detachShared','insertReusable'):
assert method in js
assert '复制:独立编辑' in html
assert '关联插入' in js