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} from 'node:crypto'; import sharp from 'sharp'; import {db} from '../src/lib/db'; import {probe,run} from '../src/lib/probe'; 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(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 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 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); const published=await publishLearningSeries();await publishLearningSeries(); const articles=(await request('/api/feed?section=articles')).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('