/** @jest-environment node */ import { NextRequest } from 'next/server' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { GET } from '@/app/api/external/download/route' import { isTokenExpired, verifyDownloadToken } from '@/lib/download-token' import { prisma } from '@/lib/prisma' jest.mock('@/lib/download-token', () => ({ isTokenExpired: jest.fn(), verifyDownloadToken: jest.fn(), })) jest.mock('@/lib/prisma', () => ({ prisma: { material: { findUnique: jest.fn(), }, }, })) const originalFetch = global.fetch const fileUrl = 'https://s3.example.test/materials/task-page.bin' const localPayload = Buffer.from( Array.from({ length: 4096 }, (_, index) => index % 256), ) let localTempDir: string let localFilePath: string function makeMaterial(assetFileUrl = fileUrl) { return { id: 'material-1', materialType: '任务-详情', createdAt: new Date('2025-01-01T00:00:00.000Z'), taskNo: 'TASK-001', assets: [ { id: 'asset-1', assetType: 'main', fileUrl: assetFileUrl, fileName: 'task-page.bin', }, ], } } function makeNonProductMaterial(overrides: Record = {}) { return { id: 'material-1', materialType: '任务-页面', createdAt: new Date('2025-01-01T00:00:00.000Z'), zipFilePath: '', zipFileName: '', productModel: 'MODEL-001', categoryName: '分类', sceneName: '场景', productCategoryName: '品类', assets: [], ...overrides, } } function makeUpstreamResponse(status: number, size: number, headers: Record) { const body = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(size)) controller.close() }, }) return new Response(body, { status, headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(size), ...headers, }, }) } function makeRequest(range?: string) { return new NextRequest('http://localhost/api/external/download?token=download-token', { headers: range ? { Range: range } : undefined, }) } describe('external single-file S3 download range forwarding', () => { beforeAll(async () => { localTempDir = await mkdtemp(join(tmpdir(), 'external-download-range-')) localFilePath = join(localTempDir, 'task-page.bin') await writeFile(localFilePath, localPayload) }) beforeEach(() => { jest.clearAllMocks() global.fetch = jest.fn() as unknown as typeof fetch ;(isTokenExpired as jest.Mock).mockReturnValue(false) ;(verifyDownloadToken as jest.Mock).mockReturnValue({ materialId: 'material-1', downloadType: 'single', assetId: 'asset-1', }) ;(prisma.material.findUnique as jest.Mock).mockResolvedValue(makeMaterial()) }) afterEach(() => { global.fetch = originalFetch }) afterAll(async () => { await rm(localTempDir, { recursive: true, force: true }) }) test('forwards Range to S3 and preserves a 206 streaming response', async () => { ;(global.fetch as jest.Mock).mockResolvedValueOnce( makeUpstreamResponse(206, 1024, { 'Content-Range': 'bytes 0-1023/4096', }), ) const response = await GET(makeRequest('bytes=0-1023')) expect(response.status).toBe(206) expect(response.headers.get('Content-Range')).toBe('bytes 0-1023/4096') expect(response.headers.get('Content-Length')).toBe('1024') expect(response.body).toBeInstanceOf(ReadableStream) expect(global.fetch).toHaveBeenCalledTimes(1) const [requestedUrl, requestInit] = (global.fetch as jest.Mock).mock.calls[0] expect(requestedUrl).toBe(fileUrl) expect(new Headers(requestInit?.headers).get('range')).toBe('bytes=0-1023') }) test('without Range keeps the full upstream 200 streaming response', async () => { ;(global.fetch as jest.Mock).mockResolvedValueOnce( makeUpstreamResponse(200, 4096, {}), ) const response = await GET(makeRequest()) expect(response.status).toBe(200) expect(response.headers.get('Content-Range')).toBeNull() expect(response.headers.get('Content-Length')).toBe('4096') expect(response.body).toBeInstanceOf(ReadableStream) expect(global.fetch).toHaveBeenCalledTimes(1) const [, requestInit] = (global.fetch as jest.Mock).mock.calls[0] expect(new Headers(requestInit?.headers).get('range')).toBeNull() }) describe('local file ranges', () => { beforeEach(() => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue(makeMaterial(localFilePath)) }) test('returns the first 1024 bytes for bytes=0-1023', async () => { const response = await GET(makeRequest('bytes=0-1023')) expect(response.status).toBe(206) expect(response.headers.get('Content-Range')).toBe('bytes 0-1023/4096') expect(response.headers.get('Content-Length')).toBe('1024') expect(Buffer.from(await response.arrayBuffer())).toEqual(localPayload.subarray(0, 1024)) expect(global.fetch).not.toHaveBeenCalled() }) test('clamps an end beyond the file to the final byte', async () => { const response = await GET(makeRequest('bytes=0-999999')) expect(response.status).toBe(206) expect(response.headers.get('Content-Range')).toBe('bytes 0-4095/4096') expect(response.headers.get('Content-Length')).toBe('4096') expect(Buffer.from(await response.arrayBuffer())).toEqual(localPayload) expect(global.fetch).not.toHaveBeenCalled() }) test('returns the final 500 bytes for a suffix range', async () => { const response = await GET(makeRequest('bytes=-500')) expect(response.status).toBe(206) expect(response.headers.get('Content-Range')).toBe('bytes 3596-4095/4096') expect(response.headers.get('Content-Length')).toBe('500') expect(Buffer.from(await response.arrayBuffer())).toEqual(localPayload.subarray(-500)) expect(global.fetch).not.toHaveBeenCalled() }) test.each(['bytes=4096-', 'bytes=0-1,2-3'])( 'rejects a true out-of-bounds or multi-range request (%s)', async (range) => { const response = await GET(makeRequest(range)) expect(response.status).toBe(416) expect(response.headers.get('Content-Range')).toBe('bytes */4096') expect(global.fetch).not.toHaveBeenCalled() }, ) }) describe('non-product package source ranges', () => { test('forwards Range to a historical task-page local ZIP and returns the requested bytes', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue( makeNonProductMaterial({ zipFilePath: localFilePath, zipFileName: 'task-page.zip', }), ) const response = await GET(makeRequest('bytes=0-1023')) expect(response.status).toBe(206) expect(response.headers.get('Content-Range')).toBe('bytes 0-1023/4096') expect(response.headers.get('Content-Length')).toBe('1024') expect(Buffer.from(await response.arrayBuffer())).toEqual(localPayload.subarray(0, 1024)) expect(global.fetch).not.toHaveBeenCalled() }) test('forwards Range for a zipFileName S3 URL when zipFilePath is empty', async () => { const s3ZipUrl = 'https://s3.example.test/materials/task-page.zip' ;(prisma.material.findUnique as jest.Mock).mockResolvedValue( makeNonProductMaterial({ zipFileName: s3ZipUrl }), ) ;(global.fetch as jest.Mock).mockResolvedValueOnce( makeUpstreamResponse(206, 1024, { 'Content-Range': 'bytes 0-1023/4096', 'Accept-Ranges': 'bytes', }), ) const response = await GET(makeRequest('bytes=0-1023')) expect(response.status).toBe(206) expect(response.headers.get('Content-Range')).toBe('bytes 0-1023/4096') expect(response.headers.get('Content-Length')).toBe('1024') expect(response.body).toBeInstanceOf(ReadableStream) expect(Buffer.from(await response.arrayBuffer())).toEqual(new Uint8Array(1024)) expect(global.fetch).toHaveBeenCalledTimes(1) const [requestedUrl, requestInit] = (global.fetch as jest.Mock).mock.calls[0] expect(requestedUrl).toBe(s3ZipUrl) expect(new Headers(requestInit?.headers).get('range')).toBe('bytes=0-1023') }) test('forwards Range for an old HTTP zipFilePath and preserves 206 headers', async () => { const legacyZipUrl = 'http://103.118.245.6/materials/task-page.zip' ;(prisma.material.findUnique as jest.Mock).mockResolvedValue( makeNonProductMaterial({ zipFilePath: legacyZipUrl }), ) ;(global.fetch as jest.Mock).mockResolvedValueOnce( makeUpstreamResponse(206, 1024, { 'Content-Range': 'bytes 0-1023/4096', 'Accept-Ranges': 'bytes', }), ) const response = await GET(makeRequest('bytes=0-1023')) expect(response.status).toBe(206) expect(response.headers.get('Content-Range')).toBe('bytes 0-1023/4096') expect(response.headers.get('Content-Length')).toBe('1024') expect(response.body).toBeInstanceOf(ReadableStream) expect(global.fetch).toHaveBeenCalledTimes(1) const [requestedUrl, requestInit] = (global.fetch as jest.Mock).mock.calls[0] expect(requestedUrl).toBe(legacyZipUrl) expect(new Headers(requestInit?.headers).get('range')).toBe('bytes=0-1023') }) test('propagates an upstream 416 Content-Range for a S3 zip URL', async () => { const s3ZipUrl = 'https://s3.example.test/materials/task-page.zip' ;(prisma.material.findUnique as jest.Mock).mockResolvedValue( makeNonProductMaterial({ zipFileName: s3ZipUrl }), ) const upstreamResponse = makeUpstreamResponse(416, 0, { 'Content-Range': 'bytes */4096', 'Accept-Ranges': 'bytes', }) ;(global.fetch as jest.Mock).mockResolvedValueOnce(upstreamResponse) const response = await GET(makeRequest('bytes=9999-')) expect(response.status).toBe(416) expect(response.headers.get('Content-Range')).toBe('bytes */4096') expect(global.fetch).toHaveBeenCalledTimes(1) const [, requestInit] = (global.fetch as jest.Mock).mock.calls[0] expect(new Headers(requestInit?.headers).get('range')).toBe('bytes=9999-') }) test('keeps a 200 full response when an upstream ignores Range', async () => { const s3ZipUrl = 'https://s3.example.test/materials/task-page.zip' ;(prisma.material.findUnique as jest.Mock).mockResolvedValue( makeNonProductMaterial({ zipFileName: s3ZipUrl }), ) ;(global.fetch as jest.Mock).mockResolvedValueOnce( makeUpstreamResponse(200, 4096, { 'Accept-Ranges': 'bytes', }), ) const response = await GET(makeRequest('bytes=0-1023')) expect(response.status).toBe(200) expect(response.headers.get('Content-Range')).toBeNull() expect(response.headers.get('Content-Length')).toBe('4096') expect(response.body).toBeInstanceOf(ReadableStream) expect(Buffer.from(await response.arrayBuffer())).toHaveLength(4096) expect(global.fetch).toHaveBeenCalledTimes(1) const [, requestInit] = (global.fetch as jest.Mock).mock.calls[0] expect(new Headers(requestInit?.headers).get('range')).toBe('bytes=0-1023') }) }) })