gongxiangp2/lib/__tests__/external-download-range.test.ts

318 lines
11 KiB
TypeScript

/** @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<string, unknown> = {}) {
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<string, string>) {
const body = new ReadableStream<Uint8Array>({
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')
})
})
})