ai-chatbot/tests/routes/canvas-zip-download.test.ts

172 lines
13 KiB
TypeScript

import { test, expect } from "@playwright/test";
import { NextRequest, NextResponse } from "next/server";
import { mkdtemp, rm, readFile, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import JSZip from "jszip";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
test("ranges reject invalid offsets and preserve normal, open and suffix byte ranges", () => {
expect(parseDownloadByteRange("bytes=0-3", 20)).toEqual({start:0,end:3});
expect(parseDownloadByteRange("bytes=10-", 20)).toEqual({start:10,end:19});
expect(parseDownloadByteRange("bytes=-4", 20)).toEqual({start:16,end:19});
for (const range of ["bytes=-0","bytes=20-","bytes=4-2","bytes=1-2,5-6","invalid"])
expect(()=>parseDownloadByteRange(range,20)).toThrow();
});
test("multi-select ZIP admission is immediate; complete archives survive restart and support authorized range retries", async () => {
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-test-')); let owner:string|null='owner';let fetches=0;
const callbacks:Array<()=>Promise<void>>=[];
const mocks={
'@/app/(auth)/auth': {auth:async()=>owner?{user:{id:owner}}:null},
'next/server': {NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>{
fetches++;return {ok:true,filename:`${index}-花瓶.png`,bytes:Buffer.from(`image-${index}`),contentType:'image/png'};
}},
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null,removeCanvasZipObject:async()=>{}},
};
const loader=()=>serverLoader(mocks,{CANVAS_DOWNLOAD_ZIP_DIR:dir})('app/api/canvas/download-zip-task/route.ts');
const request=(query:string,headers:Record<string,string>={})=>new NextRequest('http://localhost/api/canvas/download-zip-task?'+query,{headers});
const taskId=randomUUID();const files=[{url:'https://provider.example/1.png'},{url:'https://provider.example/2.png'}];
try {
const route=loader();
const response=await route.POST(new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId,folderName:'作品',files})}));
expect(response.status).toBe(202);expect(fetches).toBe(0);
expect((await route.GET(request(`taskId=${taskId}&download=1`))).status).toBe(409);
while (callbacks.length) await callbacks.shift()!();
const state=await (await route.GET(request(`taskId=${taskId}`))).json();
expect(state.task).toMatchObject({status:'completed',successCount:2,failedCount:0});
const complete=await route.GET(request(`taskId=${taskId}&download=1`));
const data=Buffer.from(await complete.arrayBuffer());const zip=await JSZip.loadAsync(data);
expect((await zip.file('作品/0-花瓶.png')!.async('nodebuffer')).toString()).toBe('image-0');
expect(complete.headers.get('content-length')).toBe(String(data.length));
const range=await route.GET(request(`taskId=${taskId}&download=1`,{range:'bytes=0-3'}));
expect(range.status).toBe(206);expect(Buffer.from(await range.arrayBuffer())).toEqual(data.subarray(0,4));
expect((await route.GET(request(`taskId=${taskId}&download=1`,{range:'bytes=999999-'}))).status).toBe(416);
expect((await route.GET(request(`taskId=${taskId}&download=1`,{range:'bytes=0-3','if-range':'"old-file"'}))).status).toBe(200);
const fresh=loader();expect((await (await fresh.GET(request(`taskId=${taskId}`))).json()).task.status).toBe('completed');expect(fetches).toBe(2);
const manifest=await readFile(join(dir,taskId,'task.json'),'utf8');expect(manifest).not.toContain('cookie');
owner='stranger';expect((await fresh.GET(request(`taskId=${taskId}`))).status).toBe(404);
owner=null;expect((await fresh.GET(request(`taskId=${taskId}`))).status).toBe(401);
} finally {await rm(dir,{recursive:true,force:true});}
});
test("all-source failures never produce an empty successful ZIP; retries reuse the task and resolve sources again", async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-retry-'));const callbacks:Array<()=>Promise<void>>=[];let available=false;
const route=serverLoader({
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>available?{ok:true,filename:'one.png',bytes:Buffer.from('one'),contentType:'image/png'}:{ok:false,error:'expired provider URL'}},
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null},
},{CANVAS_DOWNLOAD_ZIP_DIR:dir})('app/api/canvas/download-zip-task/route.ts');
const id=randomUUID();const post=()=>new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId:id,retry:true,files:[{url:'https://provider.example/expired.png'}]})});
try {
await route.POST(post());while (callbacks.length) await callbacks.shift()!();
let state=await(await route.GET(new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}`))).json();
expect(state.task.status).toBe('failed');expect(state.task.downloadUrl).toBe('');
available=true;await route.POST(post());while (callbacks.length) await callbacks.shift()!();
state=await(await route.GET(new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}`))).json();
expect(state.task).toMatchObject({id,status:'completed',successCount:1});
} finally {await rm(dir,{recursive:true,force:true});}
});
test("a stale process lock can be recovered, while a live worker cannot be duplicated",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-lock-'));const id=randomUUID();
const store=serverLoader({},{CANVAS_DOWNLOAD_ZIP_DIR:dir})('lib/canvas/download-zip-task-store.ts');
try{
const release=await store.claimZipTask(id);expect(release).toBeTruthy();expect(await store.claimZipTask(id)).toBeNull();await release();
await mkdir(join(dir,id,'lock'));await writeFile(join(dir,id,'lock','owner.json'),JSON.stringify({pid:2147483647}));
const recovered=await store.claimZipTask(id);expect(recovered).toBeTruthy();await recovered();
}finally{await rm(dir,{recursive:true,force:true});}
});
test("the browser polls a complete ZIP, preserves an idempotent task after a lost POST response, and waits to start the browser download",async()=>{
let calls=0;let firstId='';let frames:any[]=[];const stored=new Map<string,string>();
const start=serverLoader({
sessionStorage:{getItem:(key:string)=>stored.get(key),setItem:(key:string,value:string)=>stored.set(key,value)},
document:{createElement:()=>({remove(){}}),body:{appendChild:(frame:any)=>frames.push(frame)}},
window:{setTimeout:()=>0},
setTimeout:(fn:()=>void,ms:number)=>{if(ms<10000)queueMicrotask(fn);return 0;},
fetch:async(url:string,init:any)=>{
calls++;
if(init?.method==='POST'){
const payload=JSON.parse(init.body);
if(calls===1){firstId=payload.taskId;throw new Error('response disconnected');}
expect(payload.taskId).toBe(firstId);
return Response.json({task:{id:firstId,status:'pending',progress:0}});
}
expect(frames).toHaveLength(0);
return Response.json({task:{id:firstId,status:'completed',progress:100,successCount:2,failedCount:0,downloadUrl:'/api/canvas/download-zip-task?taskId='+firstId+'&download=1'}});
},
})('lib/client/canvas-zip-download.ts');
const result=await start.downloadCanvasSelectionZip({folderName:'作品',files:[{url:'https://cdn.example/a.png',name:'a'}],selectionKey:'project:node-1',onProgress:()=>{}});
expect(calls).toBe(3);expect(result.successCount).toBe(2);expect(frames).toHaveLength(1);expect(frames[0].src).toContain('download=1');
});
test("partial source failures are explicit; CDN redirects retain an authenticated local range fallback",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-partial-'));const callbacks:Array<()=>Promise<void>>=[];let owner='owner';
const route=serverLoader({
'@/app/(auth)/auth':{auth:async()=>({user:{id:owner}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>index?{ok:false,error:'source expired'}:{ok:true,filename:'original.png',bytes:Buffer.from('original'),contentType:'image/png'}},
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>({url:'https://cdn.example/complete.zip',key:'canvas-downloads/x.zip',storageConfigId:'config'})},
},{CANVAS_DOWNLOAD_ZIP_DIR:dir})('app/api/canvas/download-zip-task/route.ts');
const id=randomUUID();const request=(extra='')=>new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}${extra}`);
try{
await route.POST(new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId:id,files:[{url:'https://provider.example/1'},{url:'https://provider.example/2'}]})}));
while (callbacks.length) await callbacks.shift()!();
const state=await(await route.GET(request())).json();expect(state.task).toMatchObject({status:'completed',successCount:1,failedCount:1});
const cdn=await route.GET(request('&download=1'));expect(cdn.status).toBe(307);expect(cdn.headers.get('location')).toBe('https://cdn.example/complete.zip');
const local=await route.GET(request('&download=1&local=1'));const zip=await JSZip.loadAsync(await local.arrayBuffer());
expect(Object.keys(zip.files).some(name=>name.endsWith('_download-failures.txt'))).toBe(true);
owner='stranger';expect((await route.GET(request('&download=1'))).status).toBe(404);
}finally{await rm(dir,{recursive:true,force:true});}
});
test("recovery validates staged files instead of treating missing bytes as successful images",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-missing-'));const callbacks:Array<()=>Promise<void>>=[];const id=randomUUID();
const load=serverLoader({
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>({ok:false,error:'source expired'})},
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null},
},{CANVAS_DOWNLOAD_ZIP_DIR:dir});
try{
const store=load('lib/canvas/download-zip-task-store.ts');
await store.saveZipTask({id,userId:'owner',folderName:'test',files:[{url:'https://provider.example/a',prepared:'0.image',filename:'a.png'}],totalCount:1,processedCount:1,successCount:1,failedCount:0,compressPercent:0,status:'running',stage:'preparing',createdAt:Date.now(),updatedAt:Date.now(),expiresAt:Date.now()+60000});
const route=load('app/api/canvas/download-zip-task/route.ts');const req=()=>new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}`);
await route.GET(req());while (callbacks.length) await callbacks.shift()!();
const state=await(await route.GET(req())).json();expect(state.task).toMatchObject({status:'failed',successCount:0,failedCount:1,downloadUrl:''});
}finally{await rm(dir,{recursive:true,force:true});}
});
test("large ZIP writing checks current free space as staged input bytes are released",async()=>{
const fs=await import('node:fs/promises');const {createReadStream}=await import('node:fs');
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-space-'));let checks=0;
const module=serverLoader({'node:fs/promises':{...fs,statfs:async()=>{checks++;return {bsize:1,bavail:1024**3+33*1024**2};}}},{CANVAS_DOWNLOAD_ZIP_DIR:dir})('lib/canvas/download-zip-stream.ts');
try{
const source=join(dir,'source.bin');const handle=await fs.open(source,'w');await handle.truncate(48*1024**2);await handle.close();
const zip=new JSZip();zip.file('large.bin',createReadStream(source));
const result=await module.writeDownloadZip(zip,randomUUID(),()=>{});
expect(result.bytes).toBeGreaterThan(48*1024**2);expect(checks).toBeGreaterThan(4);
}finally{await rm(dir,{recursive:true,force:true});}
});
test("slow expired-object cleanup cannot keep the ZIP admission request open",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-cleanup-'));const callbacks:Array<()=>Promise<void>>=[];
const mocks:Record<string,unknown>={
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{},'@/lib/storage/canvas-zip-delivery':{},
};
const load=serverLoader(mocks,{CANVAS_DOWNLOAD_ZIP_DIR:dir});const store=load('lib/canvas/download-zip-task-store.ts');
mocks['@/lib/canvas/download-zip-task-store']={...store,cleanupZipTasks:()=>new Promise(()=>{})};
try{
const route=load('app/api/canvas/download-zip-task/route.ts');
const result=await Promise.race([route.POST(new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({files:[{url:'https://cdn.example/a.png'}]})})),new Promise(resolve=>setTimeout(()=>resolve('blocked'),1000))]);
expect(result).not.toBe('blocked');expect((result as Response).status).toBe(202);
}finally{await rm(dir,{recursive:true,force:true});}
});