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>=[]; const mocks={ '@/app/(auth)/auth': {auth:async()=>owner?{user:{id:owner}}:null}, 'next/server': {NextResponse,after:(run:()=>Promise)=>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={})=>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>=[];let available=false; const route=serverLoader({ '@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})}, 'next/server':{NextResponse,after:(run:()=>Promise)=>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(); 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>=[];let owner='owner'; const route=serverLoader({ '@/app/(auth)/auth':{auth:async()=>({user:{id:owner}})}, 'next/server':{NextResponse,after:(run:()=>Promise)=>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>=[];const id=randomUUID(); const load=serverLoader({ '@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})}, 'next/server':{NextResponse,after:(run:()=>Promise)=>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>=[]; const mocks:Record={ '@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})}, 'next/server':{NextResponse,after:(run:()=>Promise)=>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});} });