Files
v-hono/tests/test_multipart_upload_completeness_property.v
qinqx e7760ea80d feat: integrate multi-cloud storage (S3, OSS, COS, Local)
Add built-in storage support from v-hono-storage module:
- StorageProvider interface with 4 backends
- FileService with HTTP handlers
- Multipart upload support
- 86 property-based tests
- Retry mechanism with exponential backoff

Breaking: None (backward compatible)
2026-01-25 14:07:34 +08:00

1423 lines
37 KiB
V

module main
import rand
import time
import os
import db.sqlite
import crypto.md5
import crypto.rand as crand
// ============================================================================
// Property 5: Multipart Upload Completeness
// Feature: v-hono-upload-integration, Property 5: Multipart Upload Completeness
// Validates: Requirements 7.1, 7.3, 7.5
//
// *For any* file split into chunks, initiating a multipart upload, uploading
// all chunks, and completing the upload should result in a file identical
// to the original when downloaded.
// ============================================================================
const test_iterations = 100
const test_base_path = './test_multipart_completeness'
const test_db_path = './test_multipart_completeness/test.db'
const test_bucket = 'test-bucket'
// ============================================================================
// Type definitions (copied from storage modules for standalone testing)
// ============================================================================
struct StorageResult {
pub:
success bool
object_key string
etag string
size i64
error_msg string
}
struct ObjectInfo {
pub:
key string
size i64
etag string
content_type string
last_modified i64
metadata map[string]string
}
struct PartInfo {
pub:
part_number int
etag string
size i64
}
struct LocalStorageConfig {
pub:
base_path string = './storage'
url_prefix string = '/files'
create_dirs bool = true
}
struct FileInfo {
pub:
id int
file_uuid string
file_hash string
file_name string
file_size i64
file_type string
storage_type string
bucket string
object_key string
created_at i64
updated_at i64
metadata string
}
struct MultipartUpload {
pub:
id int
upload_id string
file_uuid string
file_name string
file_size i64
chunk_size int
total_chunks int
bucket string
object_key string
content_type string
status string
created_at i64
updated_at i64
}
struct UploadedPart {
pub:
id int
upload_id string
part_number int
etag string
size i64
uploaded_at i64
}
// ============================================================================
// LocalStorage implementation (copied for standalone testing)
// ============================================================================
struct LocalStorage {
config LocalStorageConfig
mut:
multipart_uploads map[string]MultipartUploadState
}
struct MultipartUploadState {
mut:
bucket string
key string
content_type string
parts map[int]PartInfo
created_at i64
}
fn new_local_storage(config LocalStorageConfig) !LocalStorage {
if config.create_dirs {
os.mkdir_all(config.base_path) or {
return error('Failed to create base directory: ${err}')
}
}
return LocalStorage{
config: config
multipart_uploads: map[string]MultipartUploadState{}
}
}
fn (s LocalStorage) get_full_path(bucket string, key string) string {
return os.join_path(s.config.base_path, bucket, key)
}
fn (s LocalStorage) get_bucket_path(bucket string) string {
return os.join_path(s.config.base_path, bucket)
}
fn calculate_etag(data []u8) string {
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return '"${result}"'
}
fn new_storage_result(object_key string, etag string, size i64) StorageResult {
return StorageResult{
success: true
object_key: object_key
etag: etag
size: size
error_msg: ''
}
}
fn new_part_info(part_number int, etag string, size i64) PartInfo {
return PartInfo{
part_number: part_number
etag: etag
size: size
}
}
fn (mut s LocalStorage) upload(bucket string, key string, data []u8, content_type string) !StorageResult {
bucket_path := s.get_bucket_path(bucket)
if s.config.create_dirs {
os.mkdir_all(bucket_path) or {
return error('Failed to create bucket directory: ${err}')
}
}
full_path := s.get_full_path(bucket, key)
parent_dir := os.dir(full_path)
if parent_dir != '' && parent_dir != '.' {
os.mkdir_all(parent_dir) or {
return error('Failed to create parent directory: ${err}')
}
}
os.write_file_array(full_path, data) or {
return error('Failed to write file: ${err}')
}
etag := calculate_etag(data)
return new_storage_result(key, etag, i64(data.len))
}
fn (s LocalStorage) download(bucket string, key string) ![]u8 {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return error('Object not found: ${bucket}/${key}')
}
data := os.read_bytes(full_path) or {
return error('Failed to read file: ${err}')
}
return data
}
fn (s LocalStorage) delete(bucket string, key string) ! {
full_path := s.get_full_path(bucket, key)
if !os.exists(full_path) {
return
}
os.rm(full_path) or {
return error('Failed to delete file: ${err}')
}
}
fn (s LocalStorage) exists(bucket string, key string) !bool {
full_path := s.get_full_path(bucket, key)
return os.exists(full_path) && os.is_file(full_path)
}
fn (s LocalStorage) create_bucket(bucket string) ! {
bucket_path := s.get_bucket_path(bucket)
if os.exists(bucket_path) {
return
}
os.mkdir_all(bucket_path) or {
return error('Failed to create bucket: ${err}')
}
}
fn (mut s LocalStorage) init_multipart(bucket string, key string, content_type string) !string {
upload_id := generate_upload_id()
s.multipart_uploads[upload_id] = MultipartUploadState{
bucket: bucket
key: key
content_type: content_type
parts: map[int]PartInfo{}
created_at: time.now().unix()
}
return upload_id
}
fn (mut s LocalStorage) upload_part(bucket string, key string, upload_id string, part_number int, data []u8) !string {
if upload_id !in s.multipart_uploads {
return error('Upload not found: ${upload_id}')
}
etag := calculate_etag(data)
// Store part data in temp file
temp_dir := os.join_path(s.config.base_path, '.multipart', upload_id)
os.mkdir_all(temp_dir) or {
return error('Failed to create temp directory: ${err}')
}
part_path := os.join_path(temp_dir, '${part_number}')
os.write_file_array(part_path, data) or {
return error('Failed to write part: ${err}')
}
s.multipart_uploads[upload_id].parts[part_number] = new_part_info(part_number, etag, i64(data.len))
return etag
}
fn (mut s LocalStorage) complete_multipart(bucket string, key string, upload_id string, parts []PartInfo) !StorageResult {
if upload_id !in s.multipart_uploads {
return error('Upload not found: ${upload_id}')
}
// Merge all parts
mut all_data := []u8{}
temp_dir := os.join_path(s.config.base_path, '.multipart', upload_id)
mut sorted_parts := parts.clone()
sorted_parts.sort(a.part_number < b.part_number)
for part in sorted_parts {
part_path := os.join_path(temp_dir, '${part.part_number}')
part_data := os.read_bytes(part_path) or {
return error('Failed to read part ${part.part_number}: ${err}')
}
all_data << part_data
}
// Write final file
result := s.upload(bucket, key, all_data, s.multipart_uploads[upload_id].content_type)!
// Cleanup temp files
os.rmdir_all(temp_dir) or {}
s.multipart_uploads.delete(upload_id)
return result
}
fn (mut s LocalStorage) abort_multipart(bucket string, key string, upload_id string) ! {
if upload_id !in s.multipart_uploads {
return
}
// Cleanup temp files
temp_dir := os.join_path(s.config.base_path, '.multipart', upload_id)
os.rmdir_all(temp_dir) or {}
s.multipart_uploads.delete(upload_id)
}
fn (s LocalStorage) provider_name() string {
return 'local'
}
// ============================================================================
// DatabaseManager implementation (copied for standalone testing)
// ============================================================================
struct DatabaseManager {
mut:
db sqlite.DB
}
fn new_database_manager(db_path string) !DatabaseManager {
// Ensure directory exists
parent_dir := os.dir(db_path)
if parent_dir != '' && parent_dir != '.' {
os.mkdir_all(parent_dir) or {}
}
mut db := sqlite.connect(db_path) or {
return error('Failed to connect to database: ${err}')
}
// Create tables
db.exec("CREATE TABLE IF NOT EXISTS multipart_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upload_id TEXT UNIQUE NOT NULL,
file_uuid TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
chunk_size INTEGER NOT NULL,
total_chunks INTEGER NOT NULL,
bucket TEXT NOT NULL,
object_key TEXT NOT NULL,
content_type TEXT,
status TEXT NOT NULL DEFAULT 'uploading',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);") or { return error('Failed to create multipart_uploads table: ${err}') }
db.exec('CREATE TABLE IF NOT EXISTS uploaded_parts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upload_id TEXT NOT NULL,
part_number INTEGER NOT NULL,
etag TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at INTEGER NOT NULL,
UNIQUE(upload_id, part_number)
);') or { return error('Failed to create uploaded_parts table: ${err}') }
db.exec("CREATE TABLE IF NOT EXISTS file_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_uuid TEXT UNIQUE NOT NULL,
file_hash TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
file_type TEXT NOT NULL,
storage_type TEXT NOT NULL DEFAULT 'local',
bucket TEXT NOT NULL DEFAULT 'default',
object_key TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata TEXT
);") or { return error('Failed to create file_info table: ${err}') }
return DatabaseManager{
db: db
}
}
fn generate_file_uuid() string {
random_bytes := crand.bytes(16) or { return '' }
mut uuid := ''
for i, b in random_bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
uuid += '-'
}
uuid += '${b:02x}'
}
return uuid
}
fn generate_upload_id() string {
random_bytes := crand.bytes(16) or { return '' }
mut id := ''
for b in random_bytes {
id += '${b:02x}'
}
return id
}
fn escape_sql(s string) string {
return s.replace("'", "''")
}
fn (mut dm DatabaseManager) create_multipart_upload(upload MultipartUpload) !MultipartUpload {
now := time.now().unix()
upload_id := if upload.upload_id != '' { upload.upload_id } else { generate_upload_id() }
file_uuid := if upload.file_uuid != '' { upload.file_uuid } else { generate_file_uuid() }
dm.db.exec("INSERT INTO multipart_uploads (upload_id, file_uuid, file_name, file_size, chunk_size, total_chunks, bucket, object_key, content_type, status, created_at, updated_at) VALUES ('${upload_id}', '${file_uuid}', '${escape_sql(upload.file_name)}', ${upload.file_size}, ${upload.chunk_size}, ${upload.total_chunks}, '${upload.bucket}', '${escape_sql(upload.object_key)}', '${upload.content_type}', 'uploading', ${now}, ${now})") or {
return error('Failed to create multipart upload: ${err}')
}
return MultipartUpload{
id: 0
upload_id: upload_id
file_uuid: file_uuid
file_name: upload.file_name
file_size: upload.file_size
chunk_size: upload.chunk_size
total_chunks: upload.total_chunks
bucket: upload.bucket
object_key: upload.object_key
content_type: upload.content_type
status: 'uploading'
created_at: now
updated_at: now
}
}
fn (dm DatabaseManager) get_multipart_upload(upload_id string) !MultipartUpload {
rows := dm.db.exec("SELECT id, upload_id, file_uuid, file_name, file_size, chunk_size, total_chunks, bucket, object_key, content_type, status, created_at, updated_at FROM multipart_uploads WHERE upload_id = '${upload_id}'") or {
return error('Failed to query multipart upload: ${err}')
}
if rows.len > 0 {
row := rows[0]
return MultipartUpload{
id: row.vals[0].int()
upload_id: row.vals[1]
file_uuid: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
chunk_size: row.vals[5].int()
total_chunks: row.vals[6].int()
bucket: row.vals[7]
object_key: row.vals[8]
content_type: row.vals[9]
status: row.vals[10]
created_at: row.vals[11].i64()
updated_at: row.vals[12].i64()
}
}
return error('Multipart upload not found')
}
fn (mut dm DatabaseManager) update_multipart_status(upload_id string, status string) ! {
now := time.now().unix()
dm.db.exec("UPDATE multipart_uploads SET status = '${status}', updated_at = ${now} WHERE upload_id = '${upload_id}'") or {
return error('Failed to update multipart status: ${err}')
}
}
fn (mut dm DatabaseManager) delete_multipart_upload(upload_id string) ! {
dm.db.exec("DELETE FROM uploaded_parts WHERE upload_id = '${upload_id}'") or {
return error('Failed to delete uploaded parts: ${err}')
}
dm.db.exec("DELETE FROM multipart_uploads WHERE upload_id = '${upload_id}'") or {
return error('Failed to delete multipart upload: ${err}')
}
}
fn (dm DatabaseManager) list_pending_multipart_uploads() ![]MultipartUpload {
rows := dm.db.exec("SELECT id, upload_id, file_uuid, file_name, file_size, chunk_size, total_chunks, bucket, object_key, content_type, status, created_at, updated_at FROM multipart_uploads WHERE status = 'uploading' ORDER BY created_at DESC") or {
return error('Failed to list pending uploads: ${err}')
}
mut uploads := []MultipartUpload{}
for row in rows {
uploads << MultipartUpload{
id: row.vals[0].int()
upload_id: row.vals[1]
file_uuid: row.vals[2]
file_name: row.vals[3]
file_size: row.vals[4].i64()
chunk_size: row.vals[5].int()
total_chunks: row.vals[6].int()
bucket: row.vals[7]
object_key: row.vals[8]
content_type: row.vals[9]
status: row.vals[10]
created_at: row.vals[11].i64()
updated_at: row.vals[12].i64()
}
}
return uploads
}
fn (mut dm DatabaseManager) record_uploaded_part(upload_id string, part_number int, etag string, size i64) !UploadedPart {
now := time.now().unix()
dm.db.exec("INSERT OR REPLACE INTO uploaded_parts (upload_id, part_number, etag, size, uploaded_at) VALUES ('${upload_id}', ${part_number}, '${etag}', ${size}, ${now})") or {
return error('Failed to record uploaded part: ${err}')
}
dm.db.exec("UPDATE multipart_uploads SET updated_at = ${now} WHERE upload_id = '${upload_id}'") or {}
return UploadedPart{
id: 0
upload_id: upload_id
part_number: part_number
etag: etag
size: size
uploaded_at: now
}
}
fn (dm DatabaseManager) get_uploaded_parts(upload_id string) ![]UploadedPart {
rows := dm.db.exec("SELECT id, upload_id, part_number, etag, size, uploaded_at FROM uploaded_parts WHERE upload_id = '${upload_id}' ORDER BY part_number ASC") or {
return error('Failed to get uploaded parts: ${err}')
}
mut parts := []UploadedPart{}
for row in rows {
parts << UploadedPart{
id: row.vals[0].int()
upload_id: row.vals[1]
part_number: row.vals[2].int()
etag: row.vals[3]
size: row.vals[4].i64()
uploaded_at: row.vals[5].i64()
}
}
return parts
}
fn (dm DatabaseManager) is_part_uploaded(upload_id string, part_number int) bool {
rows := dm.db.exec("SELECT 1 FROM uploaded_parts WHERE upload_id = '${upload_id}' AND part_number = ${part_number} LIMIT 1") or {
return false
}
return rows.len > 0
}
fn (mut dm DatabaseManager) insert_file(file FileInfo) !FileInfo {
now := time.now().unix()
file_uuid := if file.file_uuid != '' { file.file_uuid } else { generate_file_uuid() }
dm.db.exec("INSERT INTO file_info (file_uuid, file_hash, file_name, file_size, file_type, storage_type, bucket, object_key, created_at, updated_at, metadata) VALUES ('${file_uuid}', '${file.file_hash}', '${escape_sql(file.file_name)}', ${file.file_size}, '${file.file_type}', '${file.storage_type}', '${file.bucket}', '${escape_sql(file.object_key)}', ${now}, ${now}, '${escape_sql(file.metadata)}')") or {
return error('Failed to insert file info: ${err}')
}
return FileInfo{
id: 0
file_uuid: file_uuid
file_hash: file.file_hash
file_name: file.file_name
file_size: file.file_size
file_type: file.file_type
storage_type: file.storage_type
bucket: file.bucket
object_key: file.object_key
created_at: now
updated_at: now
metadata: file.metadata
}
}
fn (mut dm DatabaseManager) close() {
dm.db.close() or {}
}
// ============================================================================
// ChunkManager implementation (copied for standalone testing)
// ============================================================================
struct ChunkManagerConfig {
pub:
default_chunk_size int = 5 * 1024 * 1024
max_chunk_size int = 100 * 1024 * 1024
min_chunk_size int = 1024 * 1024
max_parts int = 10000
retry_count int = 3
retry_delay_ms int = 1000
}
struct UploadProgress {
pub:
upload_id string
file_uuid string
file_name string
file_size i64
chunk_size int
total_chunks int
uploaded_chunks int
uploaded_bytes i64
progress_pct f64
status string
pending_parts []int
created_at i64
updated_at i64
}
struct InitMultipartParams {
pub:
bucket string
object_key string
file_name string
file_size i64
content_type string
chunk_size int
}
struct ChunkUploadResult {
pub:
upload_id string
part_number int
etag string
size i64
success bool
error_msg string
}
@[heap]
struct ChunkManager {
mut:
db DatabaseManager
storage LocalStorage
config ChunkManagerConfig
}
fn new_chunk_manager(mut db DatabaseManager, mut storage LocalStorage, config ChunkManagerConfig) ChunkManager {
return ChunkManager{
db: db
storage: storage
config: config
}
}
fn (cm ChunkManager) validate_chunk_size(size int) int {
if size < cm.config.min_chunk_size {
return cm.config.min_chunk_size
}
if size > cm.config.max_chunk_size {
return cm.config.max_chunk_size
}
return size
}
fn (cm ChunkManager) calculate_total_chunks(file_size i64, chunk_size int) int {
chunks := file_size / i64(chunk_size)
if file_size % i64(chunk_size) > 0 {
return int(chunks) + 1
}
return int(chunks)
}
fn calculate_chunk_file_hash(upload_id string, file_size i64) string {
data := '${upload_id}-${file_size}'.bytes()
hash := md5.sum(data)
mut result := ''
for b in hash {
result += '${b:02x}'
}
return result
}
fn (mut cm ChunkManager) init_multipart(params InitMultipartParams) !MultipartUpload {
if params.bucket == '' {
return error('Bucket name is required')
}
if params.object_key == '' {
return error('Object key is required')
}
if params.file_size <= 0 {
return error('File size must be positive')
}
chunk_size := if params.chunk_size > 0 {
cm.validate_chunk_size(params.chunk_size)
} else {
cm.config.default_chunk_size
}
total_chunks := cm.calculate_total_chunks(params.file_size, chunk_size)
if total_chunks > cm.config.max_parts {
return error('File too large: would require ${total_chunks} parts, max is ${cm.config.max_parts}')
}
provider_upload_id := cm.storage.init_multipart(params.bucket, params.object_key, params.content_type)!
upload := cm.db.create_multipart_upload(MultipartUpload{
upload_id: provider_upload_id
file_name: params.file_name
file_size: params.file_size
chunk_size: chunk_size
total_chunks: total_chunks
bucket: params.bucket
object_key: params.object_key
content_type: params.content_type
})!
return upload
}
fn (mut cm ChunkManager) upload_part(upload_id string, part_number int, data []u8) !ChunkUploadResult {
upload := cm.db.get_multipart_upload(upload_id) or {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Upload not found: ${upload_id}'
}
}
if upload.status != 'uploading' {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Upload is not in uploading state: ${upload.status}'
}
}
if part_number < 1 || part_number > upload.total_chunks {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Invalid part number: ${part_number}, expected 1-${upload.total_chunks}'
}
}
etag := cm.storage.upload_part(upload.bucket, upload.object_key, upload_id, part_number, data) or {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Failed to upload part: ${err}'
}
}
cm.db.record_uploaded_part(upload_id, part_number, etag, i64(data.len)) or {
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
success: false
error_msg: 'Failed to record uploaded part: ${err}'
}
}
return ChunkUploadResult{
upload_id: upload_id
part_number: part_number
etag: etag
size: i64(data.len)
success: true
error_msg: ''
}
}
fn (mut cm ChunkManager) complete_multipart(upload_id string) !StorageResult {
upload := cm.db.get_multipart_upload(upload_id)!
if upload.status != 'uploading' {
return error('Upload is not in uploading state: ${upload.status}')
}
uploaded_parts := cm.db.get_uploaded_parts(upload_id)!
if uploaded_parts.len != upload.total_chunks {
return error('Not all parts uploaded: ${uploaded_parts.len}/${upload.total_chunks}')
}
mut parts := []PartInfo{}
for part in uploaded_parts {
parts << new_part_info(part.part_number, part.etag, part.size)
}
parts.sort(a.part_number < b.part_number)
result := cm.storage.complete_multipart(upload.bucket, upload.object_key, upload_id, parts)!
cm.db.update_multipart_status(upload_id, 'completed')!
file_hash := calculate_chunk_file_hash(upload_id, upload.file_size)
cm.db.insert_file(FileInfo{
file_uuid: upload.file_uuid
file_hash: file_hash
file_name: upload.file_name
file_size: upload.file_size
file_type: upload.content_type
storage_type: cm.storage.provider_name()
bucket: upload.bucket
object_key: upload.object_key
metadata: ''
}) or {}
return result
}
fn (mut cm ChunkManager) abort_multipart(upload_id string) ! {
upload := cm.db.get_multipart_upload(upload_id) or {
return error('Upload not found: ${upload_id}')
}
cm.storage.abort_multipart(upload.bucket, upload.object_key, upload_id) or {}
cm.db.update_multipart_status(upload_id, 'aborted')!
cm.db.delete_multipart_upload(upload_id)!
}
fn (cm ChunkManager) get_upload_progress(upload_id string) !UploadProgress {
upload := cm.db.get_multipart_upload(upload_id)!
uploaded_parts := cm.db.get_uploaded_parts(upload_id)!
mut uploaded_bytes := i64(0)
mut uploaded_part_numbers := map[int]bool{}
for part in uploaded_parts {
uploaded_bytes += part.size
uploaded_part_numbers[part.part_number] = true
}
mut pending_parts := []int{}
for i in 1 .. upload.total_chunks + 1 {
if i !in uploaded_part_numbers {
pending_parts << i
}
}
progress_pct := if upload.total_chunks > 0 {
f64(uploaded_parts.len) / f64(upload.total_chunks) * 100.0
} else {
0.0
}
return UploadProgress{
upload_id: upload_id
file_uuid: upload.file_uuid
file_name: upload.file_name
file_size: upload.file_size
chunk_size: upload.chunk_size
total_chunks: upload.total_chunks
uploaded_chunks: uploaded_parts.len
uploaded_bytes: uploaded_bytes
progress_pct: progress_pct
status: upload.status
pending_parts: pending_parts
created_at: upload.created_at
updated_at: upload.updated_at
}
}
// ============================================================================
// Test infrastructure
// ============================================================================
struct PropertyTestStats {
mut:
total_tests int
passed_tests int
failed_tests int
}
fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
stats.total_tests++
print('🔬 ${test_name}... ')
if test_func() {
stats.passed_tests++
println('✅')
} else {
stats.failed_tests++
println('❌')
}
}
fn (stats PropertyTestStats) print_summary() {
println('\n=== Multipart Upload Completeness 属性测试总结 ===')
println('总测试数: ${stats.total_tests}')
println('通过: ${stats.passed_tests}')
println('失败: ${stats.failed_tests}')
if stats.failed_tests == 0 {
println('🎉 所有属性测试通过!')
} else {
println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
}
}
fn generate_random_bytes(len int) []u8 {
mut result := []u8{len: len}
for i in 0 .. len {
result[i] = u8(rand.int_in_range(0, 256) or { 0 })
}
return result
}
fn generate_random_filename() string {
chars := 'abcdefghijklmnopqrstuvwxyz0123456789'
len := rand.int_in_range(5, 15) or { 10 }
mut result := ''
for _ in 0 .. len {
idx := rand.int_in_range(0, chars.len) or { 0 }
result += chars[idx].ascii_str()
}
return result + '.bin'
}
fn cleanup_test_dir() {
os.rmdir_all(test_base_path) or {}
}
fn create_test_environment() !(LocalStorage, DatabaseManager) {
os.mkdir_all(test_base_path) or {
return error('Failed to create test directory: ${err}')
}
storage_config := LocalStorageConfig{
base_path: test_base_path
url_prefix: '/files'
create_dirs: true
}
storage := new_local_storage(storage_config)!
db := new_database_manager(test_db_path)!
return storage, db
}
fn split_into_chunks(data []u8, chunk_size int) [][]u8 {
mut chunks := [][]u8{}
mut offset := 0
for offset < data.len {
end := if offset + chunk_size > data.len { data.len } else { offset + chunk_size }
chunks << data[offset..end]
offset = end
}
return chunks
}
// ============================================================================
// Property 5.1: Basic Multipart Upload Round Trip
// For any random file data split into chunks, the complete multipart upload
// process should result in identical content when downloaded
// ============================================================================
fn test_property_5_1_basic_multipart_roundtrip() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage, mut db := create_test_environment() or {
println(' Failed to create test environment: ${err}')
return false
}
defer {
db.close()
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
config := ChunkManagerConfig{
default_chunk_size: 1024
min_chunk_size: 256
max_chunk_size: 10240
max_parts: 1000
retry_count: 3
retry_delay_ms: 100
}
mut cm := new_chunk_manager(mut db, mut storage, config)
for i in 0 .. test_iterations {
file_size := rand.int_in_range(1024, 10240) or { 2048 }
original_data := generate_random_bytes(file_size)
filename := generate_random_filename()
object_key := 'multipart/${filename}'
params := InitMultipartParams{
bucket: test_bucket
object_key: object_key
file_name: filename
file_size: i64(original_data.len)
content_type: 'application/octet-stream'
chunk_size: 1024
}
upload := cm.init_multipart(params) or {
println(' Iteration ${i}: Failed to init multipart: ${err}')
return false
}
chunks := split_into_chunks(original_data, upload.chunk_size)
for part_num, chunk in chunks {
result := cm.upload_part(upload.upload_id, part_num + 1, chunk) or {
println(' Iteration ${i}: Failed to upload part ${part_num + 1}: ${err}')
return false
}
if !result.success {
println(' Iteration ${i}: Part ${part_num + 1} upload failed: ${result.error_msg}')
return false
}
}
complete_result := cm.complete_multipart(upload.upload_id) or {
println(' Iteration ${i}: Failed to complete multipart: ${err}')
return false
}
if !complete_result.success {
println(' Iteration ${i}: Complete returned failure: ${complete_result.error_msg}')
return false
}
downloaded := storage.download(test_bucket, object_key) or {
println(' Iteration ${i}: Failed to download: ${err}')
return false
}
if original_data != downloaded {
println(' Iteration ${i}: Content mismatch! Original: ${original_data.len} bytes, Downloaded: ${downloaded.len} bytes')
return false
}
storage.delete(test_bucket, object_key) or {}
}
return true
}
// ============================================================================
// Property 5.2: Chunk Count Consistency
// For any file, the number of chunks calculated should match the actual
// number of chunks needed to upload the complete file
// ============================================================================
fn test_property_5_2_chunk_count_consistency() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage, mut db := create_test_environment() or {
println(' Failed to create test environment: ${err}')
return false
}
defer {
db.close()
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
config := ChunkManagerConfig{
default_chunk_size: 1024
min_chunk_size: 256
max_chunk_size: 10240
max_parts: 1000
retry_count: 3
retry_delay_ms: 100
}
mut cm := new_chunk_manager(mut db, mut storage, config)
for i in 0 .. test_iterations {
file_size := rand.int_in_range(100, 10000) or { 1000 }
chunk_size := rand.int_in_range(256, 2048) or { 512 }
original_data := generate_random_bytes(file_size)
filename := generate_random_filename()
object_key := 'chunks/${filename}'
params := InitMultipartParams{
bucket: test_bucket
object_key: object_key
file_name: filename
file_size: i64(original_data.len)
content_type: 'application/octet-stream'
chunk_size: chunk_size
}
upload := cm.init_multipart(params) or {
println(' Iteration ${i}: Failed to init multipart: ${err}')
return false
}
expected_chunks := (file_size + upload.chunk_size - 1) / upload.chunk_size
if upload.total_chunks != expected_chunks {
println(' Iteration ${i}: Chunk count mismatch! Expected: ${expected_chunks}, Got: ${upload.total_chunks}')
println(' File size: ${file_size}, Chunk size: ${upload.chunk_size}')
cm.abort_multipart(upload.upload_id) or {}
return false
}
cm.abort_multipart(upload.upload_id) or {}
}
return true
}
// ============================================================================
// Property 5.3: Partial Upload Abort
// For any partially uploaded file, aborting should clean up all chunks
// and the file should not exist
// ============================================================================
fn test_property_5_3_partial_upload_abort() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage, mut db := create_test_environment() or {
println(' Failed to create test environment: ${err}')
return false
}
defer {
db.close()
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
config := ChunkManagerConfig{
default_chunk_size: 1024
min_chunk_size: 256
max_chunk_size: 10240
max_parts: 1000
retry_count: 3
retry_delay_ms: 100
}
mut cm := new_chunk_manager(mut db, mut storage, config)
for i in 0 .. test_iterations {
file_size := rand.int_in_range(2048, 8192) or { 4096 }
original_data := generate_random_bytes(file_size)
filename := generate_random_filename()
object_key := 'abort/${filename}'
params := InitMultipartParams{
bucket: test_bucket
object_key: object_key
file_name: filename
file_size: i64(original_data.len)
content_type: 'application/octet-stream'
chunk_size: 1024
}
upload := cm.init_multipart(params) or {
println(' Iteration ${i}: Failed to init multipart: ${err}')
return false
}
chunks := split_into_chunks(original_data, upload.chunk_size)
chunks_to_upload := rand.int_in_range(1, chunks.len) or { 1 }
for j in 0 .. chunks_to_upload {
cm.upload_part(upload.upload_id, j + 1, chunks[j]) or {}
}
cm.abort_multipart(upload.upload_id) or {
println(' Iteration ${i}: Failed to abort: ${err}')
return false
}
exists := storage.exists(test_bucket, object_key) or { false }
if exists {
println(' Iteration ${i}: File should not exist after abort')
return false
}
_ := cm.get_upload_progress(upload.upload_id) or {
continue
}
println(' Iteration ${i}: Upload record should be deleted after abort')
return false
}
return true
}
// ============================================================================
// Property 5.4: Size Consistency
// For any completed multipart upload, the final file size should match
// the sum of all chunk sizes
// ============================================================================
fn test_property_5_4_size_consistency() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage, mut db := create_test_environment() or {
println(' Failed to create test environment: ${err}')
return false
}
defer {
db.close()
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
config := ChunkManagerConfig{
default_chunk_size: 1024
min_chunk_size: 256
max_chunk_size: 10240
max_parts: 1000
retry_count: 3
retry_delay_ms: 100
}
mut cm := new_chunk_manager(mut db, mut storage, config)
for i in 0 .. test_iterations {
file_size := rand.int_in_range(1024, 8192) or { 2048 }
original_data := generate_random_bytes(file_size)
filename := generate_random_filename()
object_key := 'size/${filename}'
params := InitMultipartParams{
bucket: test_bucket
object_key: object_key
file_name: filename
file_size: i64(original_data.len)
content_type: 'application/octet-stream'
chunk_size: 1024
}
upload := cm.init_multipart(params) or {
println(' Iteration ${i}: Failed to init multipart: ${err}')
return false
}
chunks := split_into_chunks(original_data, upload.chunk_size)
mut total_uploaded := i64(0)
for part_num, chunk in chunks {
result := cm.upload_part(upload.upload_id, part_num + 1, chunk) or {
println(' Iteration ${i}: Failed to upload part: ${err}')
cm.abort_multipart(upload.upload_id) or {}
return false
}
total_uploaded += result.size
}
if total_uploaded != i64(original_data.len) {
println(' Iteration ${i}: Total uploaded size mismatch! Expected: ${original_data.len}, Got: ${total_uploaded}')
cm.abort_multipart(upload.upload_id) or {}
return false
}
complete_result := cm.complete_multipart(upload.upload_id) or {
println(' Iteration ${i}: Failed to complete: ${err}')
return false
}
if complete_result.size != i64(original_data.len) {
println(' Iteration ${i}: Final size mismatch! Expected: ${original_data.len}, Got: ${complete_result.size}')
return false
}
storage.delete(test_bucket, object_key) or {}
}
return true
}
// ============================================================================
// Property 5.5: Out of Order Upload
// For any file, uploading chunks in random order should still result in
// correct file content after completion
// ============================================================================
fn test_property_5_5_out_of_order_upload() bool {
cleanup_test_dir()
defer {
cleanup_test_dir()
}
mut storage, mut db := create_test_environment() or {
println(' Failed to create test environment: ${err}')
return false
}
defer {
db.close()
}
storage.create_bucket(test_bucket) or {
println(' Failed to create bucket: ${err}')
return false
}
config := ChunkManagerConfig{
default_chunk_size: 512
min_chunk_size: 256
max_chunk_size: 10240
max_parts: 1000
retry_count: 3
retry_delay_ms: 100
}
mut cm := new_chunk_manager(mut db, mut storage, config)
for i in 0 .. test_iterations {
file_size := rand.int_in_range(2048, 6144) or { 3072 }
original_data := generate_random_bytes(file_size)
filename := generate_random_filename()
object_key := 'outoforder/${filename}'
params := InitMultipartParams{
bucket: test_bucket
object_key: object_key
file_name: filename
file_size: i64(original_data.len)
content_type: 'application/octet-stream'
chunk_size: 512
}
upload := cm.init_multipart(params) or {
println(' Iteration ${i}: Failed to init multipart: ${err}')
return false
}
chunks := split_into_chunks(original_data, upload.chunk_size)
mut order := []int{}
for j in 0 .. chunks.len {
order << j
}
for j := chunks.len - 1; j > 0; j-- {
k := rand.int_in_range(0, j + 1) or { 0 }
tmp := order[j]
order[j] = order[k]
order[k] = tmp
}
for idx in order {
result := cm.upload_part(upload.upload_id, idx + 1, chunks[idx]) or {
println(' Iteration ${i}: Failed to upload part ${idx + 1}: ${err}')
cm.abort_multipart(upload.upload_id) or {}
return false
}
if !result.success {
println(' Iteration ${i}: Part ${idx + 1} failed: ${result.error_msg}')
cm.abort_multipart(upload.upload_id) or {}
return false
}
}
cm.complete_multipart(upload.upload_id) or {
println(' Iteration ${i}: Failed to complete: ${err}')
return false
}
downloaded := storage.download(test_bucket, object_key) or {
println(' Iteration ${i}: Failed to download: ${err}')
return false
}
if original_data != downloaded {
println(' Iteration ${i}: Content mismatch after out-of-order upload!')
return false
}
storage.delete(test_bucket, object_key) or {}
}
return true
}
// ============================================================================
// Main function
// ============================================================================
fn main() {
println('🚀 开始 Multipart Upload Completeness 属性测试...')
println('Feature: v-hono-upload-integration, Property 5: Multipart Upload Completeness')
println('Validates: Requirements 7.1, 7.3, 7.5')
println('每个属性测试运行 ${test_iterations} 次迭代\n')
rand.seed([u32(time.now().unix()), u32(12345)])
mut stats := PropertyTestStats{}
stats.run_property_test('Property 5.1: Basic Multipart Upload Round Trip', test_property_5_1_basic_multipart_roundtrip)
stats.run_property_test('Property 5.2: Chunk Count Consistency', test_property_5_2_chunk_count_consistency)
stats.run_property_test('Property 5.3: Partial Upload Abort', test_property_5_3_partial_upload_abort)
stats.run_property_test('Property 5.4: Size Consistency', test_property_5_4_size_consistency)
stats.run_property_test('Property 5.5: Out of Order Upload', test_property_5_5_out_of_order_upload)
stats.print_summary()
cleanup_test_dir()
if stats.failed_tests > 0 {
exit(1)
}
}