299 lines
24 KiB
TypeScript
299 lines
24 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";
|
||
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, HeadObjectCommand, PutObjectCommand, UploadPartCommand } from "@aws-sdk/client-s3";
|
||
|
||
const runtimeWork = { trackRuntimeWork: async (run: () => Promise<void>) => run() };
|
||
|
||
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={
|
||
'@/lib/observability/active-work':runtimeWork,'@/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({
|
||
'@/lib/observability/active-work':runtimeWork,'@/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({
|
||
'@/lib/observability/active-work':runtimeWork,'@/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({
|
||
'@/lib/observability/active-work':runtimeWork,'@/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>={
|
||
'@/lib/observability/active-work':runtimeWork,'@/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});}
|
||
});
|
||
|
||
test("large ZIPs use bounded parallel parts and publish only the complete ordered archive", async () => {
|
||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-parts-'));
|
||
const bytes=Buffer.alloc(25*1024**2+19,0x5a);bytes[16*1024**2]=0xff;
|
||
const file=join(dir,'large.zip');await writeFile(file,bytes);
|
||
const parts=new Map<number,Buffer>();let inFlight=0;let peak=0;let finished=false;let destroyed=false;let trailers='';
|
||
const progress:number[]=[];
|
||
const client={config:{requestChecksumCalculation:async()=>''},destroy(){destroyed=true;},async send(command:any){
|
||
if(command instanceof HeadObjectCommand)throw Object.assign(new Error('missing'),{$metadata:{httpStatusCode:404}});
|
||
if(command instanceof CreateMultipartUploadCommand){expect(command.input.ContentDisposition).toContain('attachment');return {UploadId:'upload'};}
|
||
if(command instanceof UploadPartCommand){
|
||
const input=command.input;inFlight++;peak=Math.max(peak,inFlight);expect(input.ContentLength).toBeLessThanOrEqual(8*1024**2);
|
||
await new Promise(resolve=>setTimeout(resolve,input.PartNumber===1?30:20));
|
||
expect(Buffer.isBuffer(input.Body)).toBe(true);
|
||
parts.set(input.PartNumber!,Buffer.from(input.Body as Buffer));inFlight--;return {ETag:`part-${input.PartNumber}`};
|
||
}
|
||
expect(command).toBeInstanceOf(CompleteMultipartUploadCommand);
|
||
expect(command.input.MultipartUpload.Parts.map((p:any)=>p.PartNumber)).toEqual([1,2,3,4]);
|
||
expect(Buffer.concat(command.input.MultipartUpload.Parts.map((p:any)=>parts.get(p.PartNumber)!)).equals(bytes)).toBe(true);
|
||
finished=true;trailers=await client.config.requestChecksumCalculation();return {};
|
||
}};
|
||
const publish=serverLoader({
|
||
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
|
||
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client,buildS3PublicUrl:(_:any,key:string)=>'https://cdn.example/'+key},
|
||
fetch:async(url:string,init:any)=>{expect(finished).toBe(true);expect(init.method).toBe('HEAD');expect(new URL(url).searchParams.get('v')).toBe(String(bytes.length));return new Response(null,{headers:{'content-length':String(bytes.length)}});},
|
||
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
|
||
try{
|
||
const result=await publish({id:randomUUID(),path:file,bytes:bytes.length,disposition:'attachment; filename="large.zip"',onProgress:async(n:number)=>{progress.push(n);}});
|
||
expect(result.url).toContain('https://cdn.example/canvas-downloads/');expect(peak).toBeGreaterThan(1);expect(peak).toBeLessThanOrEqual(3);expect(progress.at(-1)).toBe(bytes.length);
|
||
expect(progress).toEqual([...progress].sort((a,b)=>a-b));expect(trailers).toBe('WHEN_REQUIRED');expect(destroyed).toBe(true);
|
||
}finally{await rm(dir,{recursive:true,force:true});}
|
||
});
|
||
|
||
test("failed multipart uploads abort their parts and retain the original ZIP with actionable advice",async()=>{
|
||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-abort-'));const file=join(dir,'large.zip');const bytes=17*1024**2;
|
||
const handle=await (await import('node:fs/promises')).open(file,'w');await handle.truncate(bytes);await handle.close();
|
||
let aborted=false;let completed=false;
|
||
const client={config:{},destroy(){},async send(command:any){
|
||
if(command instanceof HeadObjectCommand)throw Object.assign(new Error('missing'),{$metadata:{httpStatusCode:404}});
|
||
if(command instanceof CreateMultipartUploadCommand)return {UploadId:'upload'};
|
||
if(command instanceof UploadPartCommand){if(command.input.PartNumber===2)throw new DOMException('part timeout','TimeoutError');return {ETag:'part'};}
|
||
if(command instanceof AbortMultipartUploadCommand){aborted=true;return {};}
|
||
completed=true;return {};
|
||
}};
|
||
const publish=serverLoader({
|
||
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
|
||
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client},
|
||
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
|
||
try{
|
||
const result=await publish({id:randomUUID(),path:file,bytes,disposition:'attachment'});
|
||
expect(result.url).toBeNull();expect(result.error).toContain('传送至七牛时超时');expect(result.error).toContain('继续传送原 ZIP');
|
||
expect(aborted).toBe(true);expect(completed).toBe(false);expect((await (await import('node:fs/promises')).stat(file)).size).toBe(bytes);
|
||
}finally{await rm(dir,{recursive:true,force:true});}
|
||
});
|
||
|
||
test("retrying CDN verification reuses an already uploaded ZIP without uploading it again",async()=>{
|
||
let uploads=0;let available=false;
|
||
const client={config:{},destroy(){},async send(command:any){
|
||
if(command instanceof HeadObjectCommand)return {ContentLength:100};
|
||
if(command instanceof PutObjectCommand||command instanceof CreateMultipartUploadCommand)uploads++;
|
||
throw new Error('Unexpected upload');
|
||
}};
|
||
const publish=serverLoader({
|
||
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
|
||
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client,buildS3PublicUrl:(_:any,key:string)=>'https://cdn.example/'+key},
|
||
fetch:async()=>new Response(null,{status:available?200:404,headers:{'content-length':available?'100':'0'}}),
|
||
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
|
||
const params={id:randomUUID(),path:'unused-already-published.zip',bytes:100,disposition:'attachment'};
|
||
const first=await publish(params);expect(first.url).toBeNull();expect(first.error).toContain('七牛下载链接尚未可用');
|
||
available=true;expect((await publish(params)).url).toContain('https://cdn.example/');expect(uploads).toBe(0);
|
||
});
|
||
|
||
test("CDN failures never silently start local downloads; retries reuse the ZIP and keep it resumable beyond one hour",async()=>{
|
||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-cdn-retry-'));const callbacks:Array<()=>Promise<void>>=[];
|
||
let fetches=0;let publications=0;let available=false;let now=Date.now();
|
||
let activeWork=0;
|
||
const runtimeWork={trackRuntimeWork:async(run:()=>Promise<void>)=>{activeWork++;try{return await run();}finally{activeWork--;}}};
|
||
class Clock extends Date{static now(){return now;}}
|
||
const load=serverLoader({
|
||
Date:Clock,'@/lib/observability/active-work':runtimeWork,'@/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()=>{fetches++;return {ok:true,filename:'original.png',bytes:Buffer.from('original'),contentType:'image/png'};}},
|
||
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>{expect(activeWork).toBe(1);publications++;return {url:available?'https://cdn.example/complete.zip':null,key:'canvas-downloads/key.zip',storageConfigId:'storage',error:'ZIP 已打包,传送至七牛时超时;请再次点击下载继续原 ZIP,不会重新生成图片。'};},removeCanvasZipObject:async()=>{}},
|
||
},{CANVAS_DOWNLOAD_ZIP_DIR:dir});
|
||
const route=load('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://cdn.example/original.png'}]})});
|
||
const request=(suffix='')=>new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}${suffix}`,{headers:{range:'bytes=0-3'}});
|
||
try{
|
||
await route.POST(post());while(callbacks.length)await callbacks.shift()!();
|
||
const failed=(await(await route.GET(request())).json()).task;
|
||
expect(failed).toMatchObject({id,status:'failed',successCount:1,downloadUrl:''});expect(failed.error).toContain('不会重新生成图片');
|
||
expect((await route.GET(request('&download=1'))).status).toBe(409);
|
||
const before=await readFile(join(dir,id,'task.json'),'utf8');const original=Buffer.from(await readFile(JSON.parse(before).zipFile.path));
|
||
available=true;await route.POST(post());while(callbacks.length)await callbacks.shift()!();
|
||
expect(fetches).toBe(1);expect(publications).toBe(2);
|
||
const manifest=JSON.parse(await readFile(join(dir,id,'task.json'),'utf8'));expect(await readFile(manifest.zipFile.path)).toEqual(original);
|
||
delete manifest.cdnUrl;
|
||
await load('lib/canvas/download-zip-task-store.ts').saveZipTask(manifest);
|
||
await route.POST(post());while(callbacks.length)await callbacks.shift()!();
|
||
expect(fetches).toBe(1);expect(publications).toBe(3);
|
||
expect(activeWork).toBe(0);
|
||
now+=2*60*60_000;
|
||
expect((await route.GET(request('&download=1'))).status).toBe(307);
|
||
const local=await route.GET(request('&download=1&local=1'));expect(local.status).toBe(206);expect(Buffer.from(await local.arrayBuffer())).toEqual(original.subarray(0,4));
|
||
}finally{await rm(dir,{recursive:true,force:true});}
|
||
});
|
||
|
||
test("small ZIP delivery preserves the complete file and download filename",async()=>{
|
||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-small-'));const file=join(dir,'small.zip');const bytes=Buffer.from('PK small complete archive');await writeFile(file,bytes);
|
||
let written=false;
|
||
const client={config:{},destroy(){},async send(command:any){
|
||
if(command instanceof HeadObjectCommand)throw Object.assign(new Error('missing'),{$metadata:{httpStatusCode:404}});
|
||
expect(command).toBeInstanceOf(PutObjectCommand);expect(command.input.ContentLength).toBe(bytes.length);expect(command.input.ContentDisposition).toContain('small.zip');
|
||
const chunks:Buffer[]=[];for await(const chunk of command.input.Body)chunks.push(Buffer.from(chunk));
|
||
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);written=true;return {};
|
||
}};
|
||
const publish=serverLoader({
|
||
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
|
||
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client,buildS3PublicUrl:(_:any,key:string)=>'https://cdn.example/'+key},
|
||
fetch:async()=>{expect(written).toBe(true);return new Response(null,{headers:{'content-length':String(bytes.length)}});},
|
||
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
|
||
try{expect((await publish({id:randomUUID(),path:file,bytes:bytes.length,disposition:'attachment; filename="small.zip"'})).url).toContain('https://cdn.example/');}
|
||
finally{await rm(dir,{recursive:true,force:true});}
|
||
});
|