Files
probably-rendering-one-more…/apps/waterfall-wiki/tests/integration.test.ts
T
codex-bot 8944e59ead
creative workspace review checks / verify (pull_request) Has been cancelled
Align canvas integration with corrected workspace UI and auth checks
2026-10-30 18:14:26 +08:00

305 lines
41 KiB
TypeScript

import {verifyCanvasWorkspace} from './canvas-integration';
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {spawn} from 'node:child_process';
import {once} from 'node:events';
import {createServer} from 'node:net';
import {setTimeout} from 'node:timers/promises';
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {randomUUID,createHash} from 'node:crypto';
import {putFile} from '../src/lib/storage';
import managedMedia from '../src/content/managed-media.json';
import sharp from 'sharp';
import {db} from '../src/lib/db';
import {probe,run} from '../src/lib/probe';
import seriesExample from '../fixtures/autumn-journal.json';
import {verifyGuidancePublishing} from './guidance-integration';
import {verifyEmailAccounts} from './account-integration';
import {verifyFlatNoteMigration} from './flat-note-migration';
import {staticEntries} from '../src/lib/explore';
import {publishLearningSeries} from '../src/lib/publish-learning-series';
test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processing', {timeout:300000}, async()=>{
assert.equal(process.env.PROMPT_INTEGRATION_TEST,'1','Run only against an explicitly isolated test database');
const socket=createServer();socket.listen(0,'127.0.0.1');await once(socket,'listening');const address=socket.address();assert.ok(address&&typeof address!=='string');const port=address.port;await new Promise<void>(r=>socket.close(()=>r()));
const external=process.env.PROMPT_TEST_BASE_URL;
const base=external||`http://127.0.0.1:${port}`;
const server=external?null:spawn(process.execPath,['.next/standalone/server.js'],{env:{...process.env,PORT:String(port),HOSTNAME:'127.0.0.1'},stdio:'inherit'});
const worker=external?null:spawn(process.execPath,['dist/worker.cjs'],{env:process.env,stdio:'inherit'});const dir=await mkdtemp(join(tmpdir(),'prompt-test-'));
const request=async(path:string,body?:unknown)=>{const res=await fetch(base+path,body?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}:undefined);assert.ok(res.ok,`${path}: ${res.status} ${res.ok?'':await res.text()}`);return res.json();};
try{
let ready=false;for(let i=0;i<60;i++){try{if((await fetch(base+'/api/health')).ok){ready=true;break;}}catch{}await setTimeout(250);}assert.ok(ready);
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route);
await verifyEmailAccounts();
await verifyCanvasWorkspace(base);
await verifyGuidancePublishing(base);
const workspaceEnv:NodeJS.ProcessEnv={...process.env,WORKSPACE_TEST_URL:base};
delete workspaceEnv.NODE_TEST_CONTEXT;
const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:workspaceEnv,timeout:90000,stdio:'inherit'});
const [workspaceExit]=await once(workspaceUI,'exit');assert.equal(workspaceExit,0,'SaaS workspace UI regression checks');
await verifyFlatNoteMigration();
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'});
const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag');
assert.equal((await db().query("SELECT count(*)::integer AS n FROM information_schema.columns WHERE table_schema=current_schema() AND table_name='tags' AND column_name='parent_id'")).rows[0].n,0);
const a=await sharp({create:{width:160,height:220,channels:3,background:'#b8c9af'}}).png().toBuffer();
const upload=async(kind:string,files:{data:Uint8Array;name:string;mime:string}[])=>{const form=new FormData();form.set('kind',kind);form.set('title','测试收藏 '+kind);form.set('description','可以复用的独立文字');form.set('tags',JSON.stringify([child.id]));files.forEach(f=>form.append('files',new Blob([new Uint8Array(f.data)],{type:f.mime}),f.name));const res=await fetch(base+'/api/upload',{method:'POST',body:form});assert.equal(res.status,201,await res.clone().text());return res.json();};
const first=await upload('image',[{data:a,name:'a.png',mime:'image/png'},{data:a,name:'b.png',mime:'image/png'}]);
const reused=await request('/api/posts',{kind:'image',title:'同一张图的另一种用法',description:'同一素材的独立说明',assets:[{id:first.assets[0]}],tags:[root.id]});
assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[first.assets[0]])).rows[0].n,2);
const filtered=await request('/api/feed?section=images&path=lines');assert.equal(filtered.posts.length,1);assert.equal(filtered.posts.find((p:{id:string})=>p.id===reused.id).description,'同一素材的独立说明');assert.equal((await request('/api/feed?section=images&path=white')).posts.length,1);
const empty=randomUUID();await db().query("INSERT INTO posts(id,slug,kind,title) VALUES($1,$2,'image','空对象')",[empty,empty]);assert.equal((await request('/api/feed')).posts.length,2);
assert.equal((await request('/api/feed')).contributions.length,0,'Import does not invent contribution rules');
await db().query("INSERT INTO contribution_events(id,kind,subject_type,amount) VALUES($1,'test.import','asset',2),($2,'test.edit','post',3)",[randomUUID(),randomUUID()]);assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5);
const img=await fetch(base+'/api/media/'+first.assets[0],{headers:{Range:'bytes=0-9'}});assert.equal(img.status,206);assert.equal((await img.arrayBuffer()).byteLength,10);
const lightweight=await fetch(base+'/api/preview?source='+encodeURIComponent('/api/media/'+first.assets[0])+'&width=480');assert.equal(lightweight.status,200);assert.match(lightweight.headers.get('content-type')||'',/image\/webp/);assert.ok((await lightweight.arrayBuffer()).byteLength<=100000);
const highQuality=await fetch(base+'/api/preview?source='+encodeURIComponent('/api/media/'+first.assets[0])+'&width=2160&profile=export');assert.equal(highQuality.status,200);assert.match(highQuality.headers.get('content-type')||'',/image\/webp/);const exportMeta=await sharp(Buffer.from(await highQuality.arrayBuffer())).metadata();assert.equal(exportMeta.width,160);assert.equal(exportMeta.height,220);
assert.equal((await fetch(base+'/api/preview?source='+encodeURIComponent('/api/media/'+first.assets[0])+'&width=2160')).status,400);
const textEntry=await fetch(base+'/explore/'+reused.id+'?agent_mode=True');assert.equal(textEntry.status,200);assert.match(textEntry.headers.get('content-type')||'',/text\/plain/);assert.ok((await textEntry.text()).includes(first.assets[0]));
assert.equal((await fetch(base+'/images/lines/missing')).status,404);assert.equal((await fetch(base+'/p/'+reused.slug)).status,200);
for(const sound of [true,false]){
const path=join(dir,sound?'sound.mp4':'silent.mp4');const args=['-y','-v','error','-f','lavfi','-i','color=c=blue:s=160x120:r=10'];if(sound)args.push('-f','lavfi','-i','sine=frequency=440:sample_rate=44100');args.push('-t','0.5','-c:v','libx264','-pix_fmt','yuv420p');if(sound)args.push('-c:a','aac');args.push(path);await run('ffmpeg',args);
const post=await upload('video',[{data:await readFile(path),name:'video.mp4',mime:'video/mp4'}]);let asset;
for(let i=0;i<120;i++){asset=(await db().query('SELECT * FROM assets WHERE id=$1',[post.assets[0]])).rows[0];if(asset.processing_status==='ready'||asset.processing_status==='failed')break;await setTimeout(500);}
assert.equal(asset.processing_status,'ready',asset.processing_error);assert.equal(asset.has_audio,sound);
const variants:string[]=(await db().query('SELECT derivation AS role FROM assets WHERE source_asset_id=$1',[asset.id])).rows.map(r=>r.role);assert.ok(variants.includes('silent_video'));assert.ok(variants.includes('poster'));assert.equal(variants.includes('audio_track'),sound);
const linked=(await request('/api/feed?section=videos')).posts.find((p:{id:string})=>p.id===post.id);assert.ok(linked.assets.some((a:{role:string})=>a.role==='poster'));
const videoNote=await fetch(base+'/explore/'+post.id+'?agent_mode=True').then(r=>r.text());assert.ok(videoNote.includes('静音视频'));
const reusedVideo=await request('/api/posts',{kind:'video',title:'复用已加工的视频',assets:[{id:asset.id}]});assert.ok((await db().query("SELECT 1 FROM post_assets WHERE post_id=$1 AND role='silent_video'",[reusedVideo.id])).rowCount);
const clip:Response=await fetch(base+'/api/media/'+asset.id+'?variant=silent_video');assert.equal(clip.status,200);const derived=join(dir,'derived.mp4');await writeFile(derived,Buffer.from(await clip.arrayBuffer()));assert.equal((await probe(derived)).streams.some(s=>s.codec_type==='audio'),false);
}
assert.equal((await db().query("SELECT count(*)::integer AS n FROM assets WHERE kind='audio' AND derivation='audio_track'")).rows[0].n,1,'Video soundtracks are ordinary reusable assets');
const audio=join(dir,'audio.wav');await run('ffmpeg',['-y','-v','error','-f','lavfi','-i','sine=frequency=220','-t','0.2',audio]);await upload('audio',[{data:await readFile(audio),name:'audio.wav',mime:'audio/wav'}]);assert.equal((await request('/api/feed?section=audio')).posts.length,1);
for(const removed of ['/library/assets','/library/tags'])assert.equal((await fetch(base+removed)).status,404);
assert.equal((await db().query("SELECT 1 FROM schema_migrations WHERE version='009_studio_designs'")).rowCount,1,'Studio migration must be recorded for repeat deployments');
// Studio owns no second database: designs use this same isolated PostgreSQL.
for(let i=1;i<=27;i++){const layoutId='ref-'+String(i).padStart(2,'0'),saved=await request('/api/studio/designs',{layoutId,name:'布局 '+i,parameters:{}});assert.equal(saved.design.layoutId,layoutId);assert.equal(saved.design.revision,1);assert.ok(saved.design.postId);assert.equal(saved.design.postUrl,'/explore/'+saved.design.postId);assert.equal(saved.design.url,'/studio/template-layout?id='+saved.design.id);const wiki=await request('/api/notes/'+saved.design.postId);assert.ok(wiki.entry.editableMarkdown.includes(saved.design.url));const read=await request('/api/studio/designs/'+saved.design.id);assert.deepEqual(read.design.parameters,{texts:{},images:{},decorations:{}});}
// Exercise actual HTTP + PostgreSQL transactions, not a mocked persistence layer.
const seriesBody=structuredClone(seriesExample);
const group=await request('/api/studio/series',seriesBody);assert.equal(group.design.schemaVersion,2);assert.equal(group.design.document.pages.length,3);assert.ok(group.design.postId);
const seriesId=group.design.id;
const exportPlan=await request('/api/studio/series/'+seriesId+'/export');assert.equal(exportPlan.pages.length,3);assert.equal(exportPlan.pages[0].height,1440);
const patchSeries={...seriesBody,revision:1,name:'Updated series'};
const edits=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/series/'+seriesId,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patchSeries)})));assert.deepEqual(edits.map(r=>r.status).sort(),[200,409]);
const badSeries=structuredClone(seriesBody);const absentAsset=randomUUID();Object.assign(badSeries.document.pages[1].parameters.images.I01,{assetId:absentAsset});(badSeries.document.sources as unknown[]).push({assetId:absentAsset,author:'CI',license:'test',url:'generated',prompt:'fixture',tool:'test'});
const countBefore=(await db().query('SELECT count(*) FROM studio_designs')).rows[0].count;
assert.equal((await fetch(base+'/api/studio/series',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(badSeries)})).status,400);assert.equal((await db().query('SELECT count(*) FROM studio_designs')).rows[0].count,countBefore);
assert.equal((await fetch(base+'/api/studio/designs/'+seriesId,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'wrong API',revision:2,parameters:{}})})).status,409);
assert.equal((await fetch(base+'/api/studio/series/'+seriesId,{method:'DELETE',headers:{'Content-Type':'application/json'},body:'{"revision":2}'})).status,200);
assert.equal((await fetch(base+'/api/studio/series/'+seriesId+'/export')).status,404);
assert.equal((await request('/api/studio/series/'+seriesId,{revision:3,archived:false})).design.revision,4);
const design=await request('/api/studio/designs',{layoutId:'ref-01',name:'可修改效果',parameters:{texts:{T01:{value:'新的标题'}},images:{I01:{assetId:first.assets[0],fit:'contain'}}}});
const designEdit={name:'更新效果',parameters:{texts:{T01:{value:'修改后'}}},revision:design.design.revision};
const patches=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/designs/'+design.design.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(designEdit)})));assert.deepEqual(patches.map(r=>r.status).sort(),[200,409]);
const designRead=await request('/api/studio/designs/'+design.design.id);assert.equal(designRead.design.revision,2);assert.equal(designRead.design.parameters.texts.T01.value,'修改后');
assert.equal(designRead.design.postId,design.design.postId,'Updating a page reuses its Wiki post');
const linkedWiki=await request('/api/notes/'+design.design.postId);assert.equal(linkedWiki.entry.title,'更新效果');
assert.equal(linkedWiki.entry.coverAssetId,first.assets[0],'An uploaded page image supplies the initial cover');
const repeated=await Promise.all([1,2].map(()=>request('/api/studio/designs/'+design.design.id+'/post',{})));
assert.ok(repeated.every(r=>r.created===false&&r.design.postId===design.design.postId&&r.design.revision===2));
const manualBody=linkedWiki.entry.editableMarkdown+'\n\n我的补充说明';
assert.equal((await fetch(base+'/api/notes/'+design.design.postId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'我的页面笔记',description:'独立的笔记说明',markdown:manualBody,tags:[],revision:linkedWiki.entry.revision})})).status,200);
const nextDesign=await fetch(base+'/api/studio/designs/'+design.design.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({...designEdit,name:'再改页面名称',revision:2})});assert.equal(nextDesign.status,200);
const retainedWiki=await request('/api/notes/'+design.design.postId);assert.equal(retainedWiki.entry.title,'我的页面笔记');assert.equal(retainedWiki.entry.editableMarkdown,manualBody);
const legacyId=randomUUID();await db().query("INSERT INTO studio_designs(id,layout_id,name,parameters) VALUES($1,'ref-01','旧保存页面',$2)",[legacyId,JSON.stringify({texts:{},images:{},decorations:{}})]);
const repaired=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/designs/'+legacyId+'/post',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})));
assert.deepEqual(repaired.map(r=>r.status).sort(),[200,201]);const repairs=await Promise.all(repaired.map(r=>r.json()));assert.equal(repairs[0].design.postId,repairs[1].design.postId);assert.equal(repairs[0].design.revision,1);
assert.equal((await db().query("SELECT count(*)::integer AS n FROM posts WHERE slug=$1",['studio-design-'+legacyId])).rows[0].n,1);
assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID()+'/post',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})).status,404);
// Both families share content validation and revision-guarded, recoverable CRUD.
for(const layoutId of ['standard-01','ref-07']){
const created=(await request('/api/studio/designs',{schemaVersion:1,layoutId,name:'CRUD '+layoutId,parameters:{texts:{T01:{value:'API 标题'}},images:{I01:{assetId:first.assets[0],fit:'cover',cropX:25,zoom:1.5}},added:[{id:'user-caption',kind:'text',role:'补充',box:[1,2,30,10],direction:'horizontal',text:'自定义',reading:'custom'}]}})).design;
assert.equal(created.family,layoutId==='standard-01'?'standard':'fixed');assert.equal(created.schemaVersion,1);
const call=(method:string,body:unknown)=>fetch(base+'/api/studio/designs/'+created.id,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
assert.equal((await call('PATCH',{name:'新标题',parameters:created.parameters,revision:1})).status,200);
assert.equal((await call('DELETE',{revision:1})).status,409);
const deleted=await call('DELETE',{revision:2});assert.equal(deleted.status,200);assert.equal((await deleted.json()).design.archived,true);
const read=(await request('/api/studio/designs/'+created.id)).design;assert.equal(read.revision,3);assert.deepEqual(read.parameters,created.parameters);
assert.equal((await request('/api/notes/'+created.postId)).entry.archived,true);
assert.ok(!(await request('/api/studio/designs?layoutId='+layoutId)).designs.some((d:{id:string})=>d.id===created.id));
assert.ok((await request('/api/studio/designs?archived=true&layoutId='+layoutId)).designs.some((d:{id:string})=>d.id===created.id));
assert.equal((await call('PATCH',{name:'禁止修改归档',parameters:{},revision:3})).status,409);
assert.equal((await call('POST',{revision:3,archived:false})).status,200);
assert.equal((await request('/api/notes/'+created.postId)).entry.archived,false);
assert.equal((await request('/api/studio/designs/'+created.id)).design.revision,4);
}
assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({schemaVersion:2,layoutId:'standard-01',name:'未来版本',parameters:{}})})).status,400);
// A post failure must roll back the design and text assets as one unit.
const beforeFailure=(await db().query('SELECT (SELECT count(*) FROM studio_designs)::integer AS designs,(SELECT count(*) FROM posts)::integer AS posts,(SELECT count(*) FROM assets)::integer AS assets')).rows[0];
await db().query(`CREATE FUNCTION reject_studio_post_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.title='atomic-studio-post-test' THEN RAISE EXCEPTION 'test publication failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_studio_post_test BEFORE INSERT ON posts FOR EACH ROW EXECUTE FUNCTION reject_studio_post_test()`);
try{assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:'ref-01',name:'atomic-studio-post-test',parameters:{}})})).status,500)}finally{await db().query('DROP TRIGGER reject_studio_post_test ON posts; DROP FUNCTION reject_studio_post_test()')}
assert.deepEqual((await db().query('SELECT (SELECT count(*) FROM studio_designs)::integer AS designs,(SELECT count(*) FROM posts)::integer AS posts,(SELECT count(*) FROM assets)::integer AS assets')).rows[0],beforeFailure);
// The migration adopts an existing manual post instead of publishing a duplicate.
const adoptedId=randomUUID(),adoptedUrl='/studio/layouts/ref-01?id='+adoptedId,adoptedPost=await request('/api/posts',{kind:'article',title:'已补录的保存页',slug:'studio-design-'+adoptedId,text:'[打开页面]('+adoptedUrl+')'});
await db().query("INSERT INTO studio_designs(id,layout_id,name,parameters) VALUES($1,'ref-01','已补录的保存页',$2)",[adoptedId,JSON.stringify({texts:{},images:{},decorations:{}})]);
await db().query(await readFile(new URL('../db/010_studio_design_posts.sql',import.meta.url),'utf8'));
const adopted=await request('/api/studio/designs/'+adoptedId);assert.equal(adopted.design.postId,adoptedPost.id);assert.equal((await request('/api/studio/designs/'+adoptedId+'/post',{})).created,false);
const designPage=await request('/api/studio/designs?layoutId=ref-01&limit=1');assert.equal(designPage.designs.length,1);assert.ok(designPage.nextCursor);assert.equal((await request('/api/studio/designs?layoutId=ref-01&limit=1&before='+designPage.nextCursor)).designs.length,1);
for(const body of [{layoutId:'ref-28',name:'bad',parameters:{}},{layoutId:'ref-01',name:'bad',parameters:{texts:{I01:{value:'wrong kind'}}}},{layoutId:'ref-01',name:'bad',parameters:{images:{I01:{assetId:randomUUID()}}}}])assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).status,400);
assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID())).status,404);
assert.ok((await fetch(base+'/api/studio/designs?agent_mode=True').then(r=>r.text())).includes('revision'));
// Isolated S3 uses synthetic fixtures, never credentials or production objects.
const testCoverFile=join(dir,'learning-cover.png');
const testCoverBytes=await sharp({create:{width:1080,height:1440,channels:3,background:'#e8eadc'}}).png().toBuffer();
await writeFile(testCoverFile,testCoverBytes);
const testCover={...managedMedia['apps/waterfall-wiki/public/learning-skills/cover.png'],id:randomUUID(),key:'integration/learning-cover.png',sha256:createHash('sha256').update(testCoverBytes).digest('hex'),bytes:testCoverBytes.length};
await putFile(testCover.key,testCoverFile,'image/png');
const publishTestSeries=()=>publishLearningSeries(testCover);
const published=await publishTestSeries();await publishTestSeries();
const articles=(await request('/api/feed?section=articles&path=learning-skills')).posts;
assert.equal(articles.length,3,'Repeated publication must not duplicate posts');
assert.equal(new Set(articles.map((p:{cover_asset_id:string})=>p.cover_asset_id)).size,1,'Three posts share one independent image asset');
assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[published.coverId])).rows[0].n,3);
for(const slug of published.published){const response=await fetch(base+'/p/'+slug);assert.equal(response.status,200);assert.ok((await response.text()).includes('内容 ID'))}
assert.equal((await request('/api/feed?section=articles&path=learning-skills')).posts.length,3);
const coverResponse=await fetch(base+'/api/media/'+published.coverId);assert.equal(coverResponse.headers.get('content-type'),'image/png');
const coverInfo=await sharp(Buffer.from(await coverResponse.arrayBuffer())).metadata();assert.equal(coverInfo.width,1080);assert.equal(coverInfo.height,1440);
assert.equal((await request('/api/feed')).contributions.reduce((n:number,c:{total:number})=>n+c.total,0),5,'Editorial publication does not invent contribution events');
for(const slug of published.published){
assert.equal((await fetch(base+`/api/posts/${slug}/snapshots`)).status,404);
assert.equal((await fetch(base+`/p/${slug}/pages/1`)).status,404);
}
const article=await request('/api/posts',{kind:'article',title:'无代码页面的笔记',text:'普通正文与 [引用](https://example.com)'});
const articlePage=await fetch(base+'/explore/'+article.id).then(r=>r.text());assert.ok(articlePage.includes('note-inline-link'));
const remaining=(await db().query("SELECT to_regclass('asset_variants') AS variants,to_regclass('page_snapshots') AS snapshots,to_regclass('render_jobs') AS renders")).rows[0];assert.deepEqual(remaining,{variants:null,snapshots:null,renders:null});
// Both file-backed Wiki pages and media notes can be edited on their stable IDs.
const note=staticEntries.find(e=>e.namespace==='Main'&&!e.media.length)!;
const editable=await request('/api/notes/'+note.id);
const newTag=await request('/api/tags',{name:'#在线编辑'});
const updatedBody='## 新内容\n\n- 项目一\n - 子项目\n- [相关笔记](/explore/'+reused.id+')';
const edit=async(id:string,payload:unknown)=>fetch(base+'/api/notes/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const payload={title:'已编辑的 Wiki 笔记',description:'修改后的预览描述',markdown:updatedBody,tags:[newTag.id],revision:editable.entry.revision};
const saved=await edit(note.id,payload);assert.equal(saved.status,200,await saved.clone().text());
assert.equal((await request('/api/notes/'+note.id)).entry.title,payload.title);
assert.equal((await edit(note.id,{...payload,title:'旧窗口写入'})).status,409,'Stale editor must not overwrite a newer save');
const updatedText=await fetch(base+'/explore/'+note.id+'?agent_mode=True').then(r=>r.text());assert.ok(updatedText.includes(updatedBody));assert.ok(updatedText.includes('在线编辑'));
const updatedHtml=await fetch(base+'/explore/'+note.id).then(r=>r.text());assert.ok(updatedHtml.includes('<ul>'));assert.ok(updatedHtml.includes('<li>'));assert.ok(updatedHtml.includes('复制网页链接'));assert.ok(updatedHtml.includes('复制纯文本链接'));assert.ok(!updatedHtml.includes('原文件 ↗'));
const editedPreview=(await request('/api/explore-feed?category='+encodeURIComponent('在线编辑'))).entries.find((e:{id:string})=>e.id===note.id);assert.equal(editedPreview.description,payload.description);
assert.ok((await fetch(base+'/api/wiki-index').then(r=>r.text())).includes(payload.title));
const storedPost=(await request('/api/notes/'+first.id)).entry;
const oldCaption=(await db().query("SELECT a.id FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND a.kind='text'",[first.id])).rows[0].id;
const captionReuse=await request('/api/posts',{kind:'article',title:'共享文本的笔记',assets:[{id:oldCaption,role:'caption'}]});
const savedPost=await edit(first.id,{title:'修改后的图片笔记',description:'图片仍保留',markdown:'新的正文',tags:[newTag.id],revision:storedPost.revision});assert.equal(savedPost.status,200,await savedPost.clone().text());
const updatedPost=await request('/api/notes/'+first.id);assert.equal(updatedPost.entry.imageCount,2);assert.equal(updatedPost.entry.editableMarkdown,'新的正文');
assert.equal((await request('/api/notes/'+captionReuse.id)).entry.editableMarkdown,'可以复用的独立文字','Editing one note must not mutate shared text');
const authored=(await request('/api/feed?section=articles')).posts.find((p:{slug:string})=>p.slug===published.published[0]);const authoredState=await request('/api/notes/'+authored.id);
assert.equal((await edit(authored.id,{title:'保留用户修改',description:'部署不能覆盖',markdown:'手动修改的正文',tags:[newTag.id],revision:authoredState.entry.revision})).status,200);
await publishTestSeries();const retained=await request('/api/notes/'+authored.id);assert.equal(retained.entry.title,'保留用户修改');assert.equal(retained.entry.editableMarkdown,'手动修改的正文');
assert.equal((await request('/api/notes/'+note.id)).entry.title,payload.title,'Static edits outlive content publication');
assert.equal((await fetch(base+'/api/notes/missing')).status,404);
assert.equal((await fetch(base+'/api/notes/'+note.id,{method:'DELETE'})).status,405);
// API-only publication: upload independent assets, retain a stable public ID, then update cover.
const postCountBefore=(await db().query('SELECT count(*)::integer AS n FROM posts')).rows[0].n;
const assetsForm=new FormData();assetsForm.set('kind','image');assetsForm.set('assetOnly','true');
assetsForm.append('files',new Blob([new Uint8Array(a)],{type:'image/png'}),'standalone-a.png');
const secondImage=await sharp({create:{width:160,height:220,channels:3,background:'#d7c7b5'}}).png().toBuffer();
assetsForm.append('files',new Blob([new Uint8Array(secondImage)],{type:'image/png'}),'standalone-b.png');
const uploadedOnly=await fetch(base+'/api/upload',{method:'POST',body:assetsForm});assert.equal(uploadedOnly.status,201,await uploadedOnly.clone().text());
const independent=await uploadedOnly.json();assert.equal(independent.assets.length,2);assert.deepEqual(independent.files.map((file:{filename:string})=>file.filename).sort(),['standalone-a.png','standalone-b.png']);assert.deepEqual(independent.files.map((file:{id:string})=>file.id),independent.assets,'file metadata matches the returned asset IDs');
assert.equal((await db().query('SELECT count(*)::integer AS n FROM posts')).rows[0].n,postCountBefore,'assetOnly never creates a post');
const publicId=note.id;
const apiBody='## 纯文本资料\n\n```json\n{"type":"text","box":[1,2,3,4]}\n```\n\n```tsx\nexport default function Demo(){return <div/>}\n```';
const publishedNote=await request('/api/posts',{kind:'article',title:'API 发布资料',slug:'api-public-'+randomUUID(),publicId,text:apiBody,assets:independent.assets.map((id:string)=>({id})),coverAssetId:independent.assets[0],tags:[child.id]});
assert.equal(publishedNote.publicId,publicId);assert.ok(!(await fetch(base+'/api/wiki-index').then(r=>r.text())).includes('['+payload.title+']'),'Migrated code entries are not listed under stale titles');assert.equal(publishedNote.url,'/explore/'+publicId);
const publicEntry=(await request('/api/notes/'+publicId)).entry;assert.equal(publicEntry.id,publicId);assert.equal(publicEntry.postSlug,publishedNote.slug);assert.equal(publicEntry.coverAssetId,independent.assets[0]);
assert.equal((await request('/api/explore-feed')).entries.filter((e:{id:string})=>e.id===publicId).length,1,'API note replaces rather than duplicates its static predecessor');
const publicText=await fetch(base+'/explore/'+publicId+'?agent_mode=True').then(r=>r.text());assert.ok(publicText.includes(apiBody));assert.ok(publicText.includes(independent.assets[0]));
const coverPayload={title:publicEntry.title,description:publicEntry.description,markdown:publicEntry.editableMarkdown,tags:[child.id],revision:publicEntry.revision,coverAssetId:independent.assets[1]};
const coverSaved=await edit(publicId,coverPayload);assert.equal(coverSaved.status,200,await coverSaved.clone().text());
const afterCover=(await request('/api/notes/'+publicId)).entry;assert.equal(afterCover.coverAssetId,independent.assets[1]);assert.equal(afterCover.media[0].url,'/api/media/'+independent.assets[1]);assert.notEqual(afterCover.revision,publicEntry.revision);
assert.equal((await edit(publicId,coverPayload)).status,409,'Stale cover edits cannot overwrite current media');
const replaceMedia=await edit(publicId,{...coverPayload,revision:afterCover.revision,assets:[{id:independent.assets[1],role:'content'},{id:independent.assets[0],role:'caption'}]});assert.equal(replaceMedia.status,200,await replaceMedia.clone().text());
const finalPublic=(await request('/api/notes/'+publicId)).entry;assert.equal(finalPublic.imageCount,1);assert.equal(finalPublic.assets.length,2);assert.equal(finalPublic.editableMarkdown,apiBody);
const duplicatePublic=await fetch(base+'/api/posts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'article',title:'不覆盖已有 ID',publicId,text:'x'})});assert.equal(duplicatePublic.status,409);
assert.equal((await fetch(base+'/p/'+publishedNote.slug,{redirect:'manual'})).headers.get('location'),'/explore/'+publicId);
for(const removed of ['/layout-reference/index.html','/layout-reference/ref-01/index.html','/layout-reference/index.json'])assert.equal((await fetch(base+removed)).status,404,removed);
for(const doc of ['SKILL','style','upload','checks']){const r=await fetch(base+`/skills/street-people/${doc}.md`);assert.equal(r.status,200);assert.ok(r.headers.get('content-type')?.includes('text/markdown'));const text=await r.text();assert.ok(text.startsWith('# '));assert.ok(!text.includes('<html'));}
for(const doc of ['SKILL','workflow','visual-grammar','prompt','delivery']){
const path=`/skills/shadow-puppet/${doc}.md`;const response=await fetch(base+path);assert.equal(response.status,200,path);assert.ok(response.headers.get('content-type')?.includes('text/markdown'));assert.ok((await response.text()).startsWith('# '));
const head=await fetch(base+path,{method:'HEAD'});assert.equal(head.status,200);assert.equal(await head.text(),'');
if(doc!=='SKILL')assert.equal((await fetch(base+'/tools/skills/shadow-puppet/'+doc)).status,200);
}
for(const image of ['egret-example','dancer-example']){
const asset=managedMedia[`apps/waterfall-wiki/public/skills/shadow-puppet/${image}.png` as keyof typeof managedMedia];
const key='integration/legacy-'+asset.id+'.png';await putFile(key,testCoverFile,'image/png');
await db().query("INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256) VALUES($1,'image',$2,$3,'image/png',$4,$5)",[asset.id,image+'.png',key,testCoverBytes.length,testCover.sha256]);
}
for(const image of ['egret-example','dancer-example'])assert.equal((await fetch(base+`/skills/shadow-puppet/${image}.png`,{method:'HEAD'})).status,200);
for(const path of ['/skills/missing/SKILL.md','/skills/shadow-puppet/missing.md','/tools/skills/shadow-puppet/missing'])assert.equal((await fetch(base+path)).status,404,path);
assert.equal((await fetch(base+'/api/skill-docs/shadow-puppet/SKILL',{method:'POST'})).status,405);
for(const path of ['/agent','/agent/index.md','/index.md']){
const entry=await fetch(base+path);assert.equal(entry.status,200,path);assert.match(entry.headers.get('content-type')||'',/text\/markdown/);const text=await entry.text();assert.ok(text.includes('/api/documents'));assert.ok(text.includes('/api/guides'));assert.ok(!text.includes('/skills/index.md'));
}
const machineIndex=await fetch(base+'/api/guides');assert.equal(machineIndex.status,200);assert.equal((await machineIndex.json()).protocol,'linked-guidance/0.1');
const home=await fetch(base+'/').then(r=>r.text());
for(const href of ['/documents','/graphic','/infinite_canvas','/account?next=/workspace'])assert.ok(home.includes('href="'+href+'"'),`Landing page links to ${href}`);
assert.ok(home.includes('让灵感,有形。'));
for(const [oldPath,newPath] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){
const response=await fetch(base+oldPath,{redirect:'manual'});assert.equal(response.status,308);assert.equal(response.headers.get('location'),newPath);
}
// Legacy media and skill functionality stays covered by its own routes above and below.
const spec=await request('/api/skills/street-people');assert.equal(spec.generation.executor,'coding-agent');
assert.equal((await fetch(base+'/api/skills/street-people?format=md')).headers.get('content-type'),'text/markdown; charset=utf-8');
const skillUpload=async(bytes:Uint8Array,slug:string)=>{const f=new FormData();f.set('slug',slug);f.append('files',new Blob([new Uint8Array(bytes)],{type:'image/png'}),'person.png');return fetch(base+'/api/skills/street-people',{method:'POST',body:f});};
const skillSlug='skill-'+randomUUID();
assert.equal((await skillUpload(a,skillSlug)).status,400,'Opaque PNG rejected');
const opaqueAlpha=await sharp({create:{width:64,height:64,channels:4,background:'#fff'}}).png().toBuffer();
assert.equal((await skillUpload(opaqueAlpha,skillSlug)).status,400,'Unused alpha channel rejected');
const transparent=await sharp({create:{width:64,height:64,channels:4,background:'#0000'}}).composite([{input:await sharp({create:{width:32,height:32,channels:4,background:'#faf4e5'}}).png().toBuffer(),left:16,top:16}]).png().toBuffer();
const created=await skillUpload(transparent,skillSlug);assert.equal(created.status,201,await created.clone().text());const character=await created.json();assert.equal(character.slug,skillSlug);assert.equal(character.url,'/p/'+skillSlug);
assert.equal((await skillUpload(transparent,skillSlug)).status,409,'Stable slug prevents duplicate posts');
const stored=await fetch(base+'/api/media/'+character.assets[0]);assert.deepEqual(Buffer.from(await stored.arrayBuffer()),transparent,'PNG alpha and original bytes preserved in S3');
assert.equal((await fetch(base+character.url)).status,200);
// A real stored body-only match and backlinks must update through the public API.
const targetNote=await request('/api/posts',{kind:'article',title:'引用目标',text:'仅在正文中的索引验证 OrchidLexicon',tags:[root.id]});
const referring=await request('/api/posts',{kind:'article',title:'引用来源',text:`[目标](${targetNote.url}) [重复](${targetNote.url}?agent_mode=True)`,tags:[]});
const targetEntry=(await request('/api/notes/'+targetNote.id)).entry;
assert.equal(targetEntry.backlinkCount,1);assert.equal(targetEntry.backlinks[0].id,referring.id);
const bodySearch=await request('/api/explore-feed?q=orchidlexicon');assert.equal(bodySearch.total,1);assert.equal(bodySearch.entries[0].id,targetNote.id);
const mixedSearch=await request('/api/explore-feed?q='+encodeURIComponent('OrchidLexicon 线条'));assert.equal(mixedSearch.total,1);
const removeReference=await request('/api/notes/'+referring.id);
const editResponse=await fetch(base+'/api/notes/'+referring.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'引用来源',description:'更新后撤回引用',markdown:'引用已经移除',tags:[],revision:removeReference.entry.revision})});assert.equal(editResponse.status,200);
assert.equal((await request('/api/notes/'+targetNote.id)).entry.backlinkCount,0);
// System labels have a closed catalogue; ordinary # labels remain user-created.
const allLabels=await request('/api/tags'),systems=allLabels.filter((t:{kind:string})=>t.kind==='system');assert.equal(systems.length,2);
const entryLabel=systems.find((t:{system_key:string})=>t.system_key==='document-entry');assert.ok(entryLabel);
for(const body of [{name:'非法系统项',kind:'system'},{name:'#文档入口'}]){
const response=await fetch(base+'/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});assert.equal(response.status,400);
}
const plain=await request('/api/tags',{name:'#可随时添加'});assert.equal(plain.kind,'normal');
const beforeTags=(await request('/api/notes/'+targetNote.id)).entry;
const patchTags=(payload:unknown)=>fetch(base+'/api/notes/'+targetNote.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const textLinksBefore=(await db().query("SELECT a.id FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND a.kind='text' ORDER BY a.id",[targetNote.id])).rows;
const assigned=await patchTags({tags:[plain.id,entryLabel.id],revision:beforeTags.revision});assert.equal(assigned.status,200,await assigned.clone().text());
const afterTags=(await request('/api/notes/'+targetNote.id)).entry;assert.deepEqual(afterTags.categories,[plain.name]);assert.equal(afterTags.systemTags[0].system_key,'document-entry');assert.equal(afterTags.editableMarkdown,beforeTags.editableMarkdown);
assert.deepEqual((await db().query("SELECT a.id FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=$1 AND a.kind='text' ORDER BY a.id",[targetNote.id])).rows,textLinksBefore,'Tag-only PATCH preserves the existing text assets');
const systemFeed=await request('/api/explore-feed?category='+encodeURIComponent(entryLabel.name));assert.ok(systemFeed.entries.some((e:{id:string})=>e.id===targetNote.id));
assert.equal((await patchTags({tags:[],revision:beforeTags.revision})).status,409);
assert.equal((await patchTags({tags:[],revision:afterTags.revision})).status,200);
const cleared=(await request('/api/notes/'+targetNote.id)).entry;assert.equal(cleared.categories.length,0);assert.equal(cleared.systemTags.length,0);
const categoriesHtml=await fetch(base+'/categories').then(r=>r.text());const tabs=categoriesHtml.match(/aria-label="系统标签分类"[\s\S]*?<\/nav>/)?.[0]||'';assert.ok(tabs.includes('文档入口'));assert.ok(tabs.includes('参考图'));assert.ok(!tabs.includes(plain.name));
// Archive is a reversible visibility change, including code-backed public IDs.
const archiveTarget=(await request('/api/notes/'+publishedNote.publicId)).entry;
const archive=(value:boolean)=>fetch(base+'/api/posts/'+publishedNote.publicId+'/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({archived:value})});
const archivedResponse=await archive(true);assert.equal(archivedResponse.status,200);const archived=(await archivedResponse.json()).post;assert.equal(archived.archived,true);assert.ok(archived.archivedAt);
assert.equal((await (await archive(true)).json()).post.archivedAt,archived.archivedAt,'Repeated archive preserves the archive timestamp');
const archivedEntry=(await request('/api/notes/'+publishedNote.publicId)).entry;assert.equal(archivedEntry.archived,true);assert.equal(archivedEntry.editableMarkdown,archiveTarget.editableMarkdown);assert.deepEqual(archivedEntry.assets,archiveTarget.assets);assert.notEqual(archivedEntry.revision,archiveTarget.revision);
assert.ok(!(await request('/api/explore-feed?q='+encodeURIComponent(archiveTarget.title))).entries.some((e:{id:string})=>e.id===publishedNote.publicId),'Archived database note must not reappear from its static predecessor');
assert.ok(!(await request('/api/feed?q='+encodeURIComponent(archiveTarget.title))).posts.some((p:{id:string})=>p.id===publishedNote.id));
assert.ok((await request('/api/feed?archived=true&q='+encodeURIComponent(archiveTarget.title))).posts.some((p:{id:string})=>p.id===publishedNote.id));
assert.ok(!(await fetch(base+'/api/wiki-index').then(r=>r.text())).includes('['+archiveTarget.title+']'));
assert.equal((await fetch(base+'/explore/'+publishedNote.publicId)).status,200,'Direct archived links remain readable for recovery');
const restored=await archive(false);assert.equal(restored.status,200);assert.equal((await restored.json()).post.archived,false);
assert.ok((await request('/api/explore-feed?q='+encodeURIComponent(archiveTarget.title))).entries.some((e:{id:string})=>e.id===publishedNote.publicId));
assert.equal((await fetch(base+'/api/feed?archived=invalid')).status,400);
assert.equal((await fetch(base+'/api/posts/'+randomUUID()+'/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:'{"archived":true}'})).status,404);
}finally{
for(const child of [worker,server])if(child&&child.exitCode===null&&child.signalCode===null){const stopped=once(child,'exit');child.kill('SIGTERM');await stopped;}
await rm(dir,{recursive:true,force:true});await db().end();
}
});