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

340 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @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()
})
test.each(['all', 'sku'])('相同 Token 重复下载 %s:重新解析最新文件和内容,不缓存旧版本', async (downloadType) => {
;(verifyDownloadToken as jest.Mock).mockReturnValue({ materialId: 'material-1', downloadType })
for (const revision of [1, 2, 3]) {
const latestUrl = `https://s3.example.test/materials/${downloadType}-v${revision}.zip`
const payload = Uint8Array.from([revision, revision + 1, revision + 2])
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
...makeMaterial(), taskStatus: 'fetched',
assets: [{ id: `asset-${downloadType}`, assetType: downloadType, fileUrl: latestUrl, fileName: `${downloadType}.zip` }],
})
;(global.fetch as jest.Mock).mockResolvedValueOnce(new Response(payload, {
headers: { 'Content-Type': 'application/zip', 'Content-Length': String(payload.length), 'Cache-Control': 'public, max-age=31536000' },
}))
const response = await GET(makeRequest())
expect(response.status).toBe(200)
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from(payload))
expect(global.fetch).toHaveBeenLastCalledWith(latestUrl, expect.objectContaining({ cache: 'no-store' }))
}
expect(prisma.material.findUnique).toHaveBeenCalledTimes(3)
})
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')
})
})
})