convertor/tests/transfer/chunk-download.test.ts
Your Name 5322f85721 feat: 實作全域檔案傳輸機制與 .tar 封裝規範
## 主要變更

### 新增全域檔案傳輸模組 (src/transfer/)
- constants.ts: 定義傳輸常數(10MB 門檻、5MB chunk 大小)
- types.ts: 傳輸類型定義
- uploadManager.ts: 後端上傳管理器(支援直傳與 chunk)
- downloadManager.ts: 後端下載管理器(支援直傳與 chunk)
- archiveManager.ts: 封裝管理器(僅允許 .tar)
- index.ts: 統一匯出

### 新增 API 端點
- uploadChunk.tsx: Chunk 上傳 API
- downloadChunk.tsx: Chunk 下載 API

### 前端更新
- public/script.js: 整合智慧傳輸策略(≤10MB 直傳,>10MB chunk)
- public/transfer.js: 前端傳輸管理模組

### 轉換器更新
- mineru.ts: 改用 .tar 格式(不壓縮),禁止 .tar.gz
- download.tsx: 使用統一的封裝管理器

### 測試
- tests/transfer/: 完整傳輸機制測試套件
- tests/converters/mineru.test.ts: 更新以符合 .tar 規範

### 文件
- README.md: 新增檔案傳輸機制說明

## 設計原則
- 檔案 ≤10MB:直接傳輸
- 檔案 >10MB:使用 5MB chunks 分段傳輸
- 多檔輸出:僅允許 .tar 封裝(禁止 .tar.gz/.zip)
- 引擎層不感知 chunk(僅傳輸層處理)
2026-01-21 13:58:01 +08:00

208 lines
6.1 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.

/**
* Contents.CN Chunk 下載整合測試
*
* 測試大檔 chunk 下載的完整流程
*/
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
import { mkdirSync, existsSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import {
shouldUseChunkedDownload,
getChunkDownloadInfo,
getChunk,
createChunkDownloadHeaders,
} from "../../src/transfer/downloadManager";
import { CHUNK_SIZE_BYTES, CHUNK_THRESHOLD_BYTES } from "../../src/transfer/constants";
const testDir = "./test-output-chunk-download";
describe("Chunk 下載資訊測試", () => {
beforeEach(() => {
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
test("getChunkDownloadInfo 應返回正確的下載資訊", () => {
const testFile = join(testDir, "large-file.bin");
const fileSize = 25 * 1024 * 1024; // 25MB
// 建立一個假檔案(只寫入少量資料模擬)
const content = Buffer.alloc(1024, "X"); // 1KB
writeFileSync(testFile, content);
const info = getChunkDownloadInfo(testFile);
expect(info).not.toBeNull();
expect(info!.file_name).toBe("large-file.bin");
expect(info!.total_size).toBe(1024);
expect(info!.chunk_size).toBe(CHUNK_SIZE_BYTES);
expect(info!.total_chunks).toBe(1); // 1KB 只需要 1 chunk
});
test("不存在的檔案應返回 null", () => {
const info = getChunkDownloadInfo(join(testDir, "non-existent.bin"));
expect(info).toBeNull();
});
});
describe("Chunk 讀取測試", () => {
beforeEach(() => {
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
test("getChunk 應正確讀取指定的 chunk", async () => {
const testFile = join(testDir, "chunked-file.bin");
// 建立測試檔案20 bytes分成 4 個 5-byte chunks
const content = "AAAAABBBBBCCCCCDDDD"; // 19 bytes
writeFileSync(testFile, content);
// 讀取第一個 chunk5 bytes
const chunk0 = await getChunk(testFile, 0, 5);
expect(chunk0!.toString()).toBe("AAAAA");
// 讀取第二個 chunk
const chunk1 = await getChunk(testFile, 1, 5);
expect(chunk1!.toString()).toBe("BBBBB");
// 讀取第三個 chunk
const chunk2 = await getChunk(testFile, 2, 5);
expect(chunk2!.toString()).toBe("CCCCC");
// 讀取最後一個 chunk只有 4 bytes
const chunk3 = await getChunk(testFile, 3, 5);
expect(chunk3!.toString()).toBe("DDDD");
});
test("讀取超出範圍的 chunk 應返回 null", async () => {
const testFile = join(testDir, "small-file.txt");
writeFileSync(testFile, "Hello");
const chunk = await getChunk(testFile, 10, 5); // 超出範圍
expect(chunk).toBeNull();
});
test("不存在的檔案應返回 null", async () => {
const chunk = await getChunk(join(testDir, "non-existent.bin"), 0, 5);
expect(chunk).toBeNull();
});
});
describe("Chunk 下載 Headers 測試", () => {
test("createChunkDownloadHeaders 應生成正確的 headers", () => {
const info = {
total_size: 1000,
total_chunks: 4,
chunk_size: 256,
file_name: "test-file.bin",
};
const chunkData = Buffer.alloc(256, "X");
const headers = createChunkDownloadHeaders(info, 0, chunkData);
expect(headers["Content-Type"]).toBe("application/octet-stream");
expect(headers["Content-Length"]).toBe("256");
expect(headers["Content-Range"]).toBe("bytes 0-255/1000");
expect(headers["Accept-Ranges"]).toBe("bytes");
expect(headers["X-Chunk-Index"]).toBe("0");
expect(headers["X-Total-Chunks"]).toBe("4");
expect(headers["X-Total-Size"]).toBe("1000");
});
test("最後一個 chunk 的 headers 應正確", () => {
const info = {
total_size: 1000,
total_chunks: 4,
chunk_size: 256,
file_name: "test-file.bin",
};
// 最後一個 chunk 只有 232 bytes (1000 - 256*3)
const chunkData = Buffer.alloc(232, "Z");
const headers = createChunkDownloadHeaders(info, 3, chunkData);
expect(headers["Content-Length"]).toBe("232");
expect(headers["Content-Range"]).toBe("bytes 768-999/1000");
expect(headers["X-Chunk-Index"]).toBe("3");
});
});
describe("傳輸模式判斷測試", () => {
beforeEach(() => {
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
test("小檔≤10MB不應使用 chunk 下載", () => {
const smallFile = join(testDir, "small.txt");
writeFileSync(smallFile, "Small file content");
expect(shouldUseChunkedDownload(smallFile)).toBe(false);
});
test("不存在的檔案不應使用 chunk 下載", () => {
expect(shouldUseChunkedDownload(join(testDir, "non-existent.bin"))).toBe(false);
});
});
describe("完整 chunk 下載流程測試", () => {
beforeEach(() => {
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
test("應能完整讀取並合併所有 chunks", async () => {
const testFile = join(testDir, "complete-file.bin");
const originalContent = "AAAAAABBBBBBCCCCCCDDDDDDEEEEEE"; // 30 bytes
writeFileSync(testFile, originalContent);
const info = getChunkDownloadInfo(testFile);
expect(info).not.toBeNull();
// 模擬分段讀取(每個 chunk 10 bytes
const chunkSize = 10;
const totalChunks = Math.ceil(originalContent.length / chunkSize);
const chunks: Buffer[] = [];
for (let i = 0; i < totalChunks; i++) {
const chunk = await getChunk(testFile, i, chunkSize);
if (chunk) {
chunks.push(chunk);
}
}
// 合併所有 chunks
const mergedContent = Buffer.concat(chunks).toString();
expect(mergedContent).toBe(originalContent);
});
});