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)
659 lines
17 KiB
V
659 lines
17 KiB
V
module main
|
|
|
|
import rand
|
|
import time
|
|
import os
|
|
import crypto.md5
|
|
|
|
// ============================================================================
|
|
// Property 1: Upload-Download Round Trip (Local)
|
|
// Feature: v-hono-upload-integration, Property 1: Upload-Download Round Trip
|
|
// Validates: Requirements 1.2, 2.3
|
|
//
|
|
// *For any* valid file data and any storage provider (Local, S3, OSS, COS),
|
|
// uploading the file and then downloading it should return identical content.
|
|
// ============================================================================
|
|
|
|
const test_iterations = 100
|
|
const test_base_path = './test_storage_roundtrip'
|
|
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 ListOptions {
|
|
pub:
|
|
prefix string
|
|
delimiter string
|
|
max_keys int = 1000
|
|
start_after string
|
|
}
|
|
|
|
struct ListResult {
|
|
pub:
|
|
objects []ObjectInfo
|
|
common_prefixes []string
|
|
is_truncated bool
|
|
next_marker string
|
|
}
|
|
|
|
struct PresignOptions {
|
|
pub:
|
|
expires_in int = 3600
|
|
method string = 'GET'
|
|
content_type 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
|
|
}
|
|
|
|
|
|
// LocalStorage 本地存储提供者
|
|
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_object_info(key string, size i64, etag string, content_type string, last_modified i64) ObjectInfo {
|
|
return ObjectInfo{
|
|
key: key
|
|
size: size
|
|
etag: etag
|
|
content_type: content_type
|
|
last_modified: last_modified
|
|
metadata: map[string]string{}
|
|
}
|
|
}
|
|
|
|
fn new_list_result(objects []ObjectInfo, common_prefixes []string, is_truncated bool, next_marker string) ListResult {
|
|
return ListResult{
|
|
objects: objects
|
|
common_prefixes: common_prefixes
|
|
is_truncated: is_truncated
|
|
next_marker: next_marker
|
|
}
|
|
}
|
|
|
|
fn new_part_info(part_number int, etag string, size i64) PartInfo {
|
|
return PartInfo{
|
|
part_number: part_number
|
|
etag: etag
|
|
size: size
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// LocalStorage implementation (copied for standalone testing)
|
|
// ============================================================================
|
|
|
|
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 error('Object not found: ${bucket}/${key}')
|
|
}
|
|
|
|
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 (s LocalStorage) head(bucket string, key string) !ObjectInfo {
|
|
full_path := s.get_full_path(bucket, key)
|
|
|
|
if !os.exists(full_path) {
|
|
return error('Object not found: ${bucket}/${key}')
|
|
}
|
|
|
|
file_size := os.file_size(full_path)
|
|
mtime := os.file_last_mod_unix(full_path)
|
|
|
|
data := os.read_bytes(full_path) or {
|
|
return error('Failed to read file for metadata: ${err}')
|
|
}
|
|
etag := calculate_etag(data)
|
|
content_type := infer_content_type(key)
|
|
|
|
return new_object_info(key, i64(file_size), etag, content_type, mtime)
|
|
}
|
|
|
|
fn infer_content_type(key string) string {
|
|
ext := os.file_ext(key).to_lower()
|
|
match ext {
|
|
'.html', '.htm' { return 'text/html' }
|
|
'.css' { return 'text/css' }
|
|
'.js' { return 'application/javascript' }
|
|
'.json' { return 'application/json' }
|
|
'.xml' { return 'application/xml' }
|
|
'.txt' { return 'text/plain' }
|
|
'.png' { return 'image/png' }
|
|
'.jpg', '.jpeg' { return 'image/jpeg' }
|
|
'.gif' { return 'image/gif' }
|
|
'.pdf' { return 'application/pdf' }
|
|
'.zip' { return 'application/zip' }
|
|
else { return 'application/octet-stream' }
|
|
}
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// 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=== Upload-Download Round Trip 属性测试总结 ===')
|
|
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(min_len int, max_len int) []u8 {
|
|
len := rand.int_in_range(min_len, max_len) or { min_len }
|
|
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, 20) or { 10 }
|
|
mut result := ''
|
|
for _ in 0 .. len {
|
|
idx := rand.int_in_range(0, chars.len) or { 0 }
|
|
result += chars[idx].ascii_str()
|
|
}
|
|
extensions := ['.txt', '.bin', '.dat', '.json', '.xml']
|
|
ext_idx := rand.int_in_range(0, extensions.len) or { 0 }
|
|
return result + extensions[ext_idx]
|
|
}
|
|
|
|
fn generate_random_content_type() string {
|
|
types := [
|
|
'text/plain',
|
|
'application/octet-stream',
|
|
'application/json',
|
|
'image/png',
|
|
'application/xml',
|
|
]
|
|
idx := rand.int_in_range(0, types.len) or { 0 }
|
|
return types[idx]
|
|
}
|
|
|
|
fn cleanup_test_dir() {
|
|
os.rmdir_all(test_base_path) or {}
|
|
}
|
|
|
|
fn create_test_storage() !LocalStorage {
|
|
config := LocalStorageConfig{
|
|
base_path: test_base_path
|
|
url_prefix: '/files'
|
|
create_dirs: true
|
|
}
|
|
return new_local_storage(config)
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Property 1.1: Basic Upload-Download Round Trip
|
|
// For any random file data, uploading and downloading should return identical content
|
|
// ============================================================================
|
|
fn test_property_1_1_basic_roundtrip() bool {
|
|
cleanup_test_dir()
|
|
defer {
|
|
cleanup_test_dir()
|
|
}
|
|
|
|
mut storage := create_test_storage() or {
|
|
println(' Failed to create storage: ${err}')
|
|
return false
|
|
}
|
|
|
|
storage.create_bucket(test_bucket) or {
|
|
println(' Failed to create bucket: ${err}')
|
|
return false
|
|
}
|
|
|
|
for i in 0 .. test_iterations {
|
|
data := generate_random_bytes(1, 10000)
|
|
filename := generate_random_filename()
|
|
content_type := generate_random_content_type()
|
|
|
|
result := storage.upload(test_bucket, filename, data, content_type) or {
|
|
println(' Iteration ${i}: Upload failed: ${err}')
|
|
return false
|
|
}
|
|
|
|
if !result.success {
|
|
println(' Iteration ${i}: Upload returned failure: ${result.error_msg}')
|
|
return false
|
|
}
|
|
|
|
downloaded := storage.download(test_bucket, filename) or {
|
|
println(' Iteration ${i}: Download failed: ${err}')
|
|
return false
|
|
}
|
|
|
|
if data != downloaded {
|
|
println(' Iteration ${i}: Content mismatch! Original: ${data.len} bytes, Downloaded: ${downloaded.len} bytes')
|
|
return false
|
|
}
|
|
|
|
storage.delete(test_bucket, filename) or {}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// ============================================================================
|
|
// Property 1.2: Empty File Round Trip
|
|
// For empty files, uploading and downloading should return empty content
|
|
// ============================================================================
|
|
fn test_property_1_2_empty_file_roundtrip() bool {
|
|
cleanup_test_dir()
|
|
defer {
|
|
cleanup_test_dir()
|
|
}
|
|
|
|
mut storage := create_test_storage() or {
|
|
println(' Failed to create storage: ${err}')
|
|
return false
|
|
}
|
|
|
|
storage.create_bucket(test_bucket) or {
|
|
println(' Failed to create bucket: ${err}')
|
|
return false
|
|
}
|
|
|
|
for i in 0 .. test_iterations {
|
|
data := []u8{}
|
|
filename := generate_random_filename()
|
|
|
|
result := storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
|
|
println(' Iteration ${i}: Upload failed: ${err}')
|
|
return false
|
|
}
|
|
|
|
if !result.success {
|
|
println(' Iteration ${i}: Upload returned failure')
|
|
return false
|
|
}
|
|
|
|
downloaded := storage.download(test_bucket, filename) or {
|
|
println(' Iteration ${i}: Download failed: ${err}')
|
|
return false
|
|
}
|
|
|
|
if downloaded.len != 0 {
|
|
println(' Iteration ${i}: Expected empty file, got ${downloaded.len} bytes')
|
|
return false
|
|
}
|
|
|
|
storage.delete(test_bucket, filename) or {}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Property 1.3: Large File Round Trip
|
|
// For larger files (up to 500KB), uploading and downloading should return identical content
|
|
// ============================================================================
|
|
fn test_property_1_3_large_file_roundtrip() bool {
|
|
cleanup_test_dir()
|
|
defer {
|
|
cleanup_test_dir()
|
|
}
|
|
|
|
mut storage := create_test_storage() or {
|
|
println(' Failed to create storage: ${err}')
|
|
return false
|
|
}
|
|
|
|
storage.create_bucket(test_bucket) or {
|
|
println(' Failed to create bucket: ${err}')
|
|
return false
|
|
}
|
|
|
|
// Run fewer iterations for large files
|
|
for i in 0 .. 10 {
|
|
data := generate_random_bytes(100000, 500000)
|
|
filename := generate_random_filename()
|
|
|
|
result := storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
|
|
println(' Iteration ${i}: Upload failed: ${err}')
|
|
return false
|
|
}
|
|
|
|
if !result.success {
|
|
println(' Iteration ${i}: Upload returned failure')
|
|
return false
|
|
}
|
|
|
|
if result.size != i64(data.len) {
|
|
println(' Iteration ${i}: Size mismatch in result. Expected: ${data.len}, Got: ${result.size}')
|
|
return false
|
|
}
|
|
|
|
downloaded := storage.download(test_bucket, filename) or {
|
|
println(' Iteration ${i}: Download failed: ${err}')
|
|
return false
|
|
}
|
|
|
|
if data != downloaded {
|
|
println(' Iteration ${i}: Content mismatch! Original: ${data.len} bytes, Downloaded: ${downloaded.len} bytes')
|
|
return false
|
|
}
|
|
|
|
storage.delete(test_bucket, filename) or {}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// ============================================================================
|
|
// Property 1.4: Nested Path Round Trip
|
|
// For files with nested paths, uploading and downloading should work correctly
|
|
// ============================================================================
|
|
fn test_property_1_4_nested_path_roundtrip() bool {
|
|
cleanup_test_dir()
|
|
defer {
|
|
cleanup_test_dir()
|
|
}
|
|
|
|
mut storage := create_test_storage() or {
|
|
println(' Failed to create storage: ${err}')
|
|
return false
|
|
}
|
|
|
|
storage.create_bucket(test_bucket) or {
|
|
println(' Failed to create bucket: ${err}')
|
|
return false
|
|
}
|
|
|
|
for i in 0 .. test_iterations {
|
|
data := generate_random_bytes(100, 5000)
|
|
|
|
depth := rand.int_in_range(1, 5) or { 2 }
|
|
mut path_parts := []string{}
|
|
for _ in 0 .. depth {
|
|
part_len := rand.int_in_range(3, 10) or { 5 }
|
|
mut part := ''
|
|
for _ in 0 .. part_len {
|
|
idx := rand.int_in_range(0, 26) or { 0 }
|
|
part += ('a'[0] + u8(idx)).ascii_str()
|
|
}
|
|
path_parts << part
|
|
}
|
|
path_parts << generate_random_filename()
|
|
filename := path_parts.join('/')
|
|
|
|
result := storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
|
|
println(' Iteration ${i}: Upload failed for path "${filename}": ${err}')
|
|
return false
|
|
}
|
|
|
|
if !result.success {
|
|
println(' Iteration ${i}: Upload returned failure')
|
|
return false
|
|
}
|
|
|
|
downloaded := storage.download(test_bucket, filename) or {
|
|
println(' Iteration ${i}: Download failed for path "${filename}": ${err}')
|
|
return false
|
|
}
|
|
|
|
if data != downloaded {
|
|
println(' Iteration ${i}: Content mismatch for path "${filename}"')
|
|
return false
|
|
}
|
|
|
|
storage.delete(test_bucket, filename) or {}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
|
|
// ============================================================================
|
|
// Property 1.5: ETag Consistency
|
|
// For any file, the ETag returned on upload should be consistent with the content
|
|
// ============================================================================
|
|
fn test_property_1_5_etag_consistency() bool {
|
|
cleanup_test_dir()
|
|
defer {
|
|
cleanup_test_dir()
|
|
}
|
|
|
|
mut storage := create_test_storage() or {
|
|
println(' Failed to create storage: ${err}')
|
|
return false
|
|
}
|
|
|
|
storage.create_bucket(test_bucket) or {
|
|
println(' Failed to create bucket: ${err}')
|
|
return false
|
|
}
|
|
|
|
for i in 0 .. test_iterations {
|
|
data := generate_random_bytes(100, 5000)
|
|
filename := generate_random_filename()
|
|
|
|
result := storage.upload(test_bucket, filename, data, 'application/octet-stream') or {
|
|
println(' Iteration ${i}: Upload failed: ${err}')
|
|
return false
|
|
}
|
|
|
|
if result.etag == '' {
|
|
println(' Iteration ${i}: ETag is empty')
|
|
return false
|
|
}
|
|
|
|
filename2 := generate_random_filename()
|
|
result2 := storage.upload(test_bucket, filename2, data, 'application/octet-stream') or {
|
|
println(' Iteration ${i}: Second upload failed: ${err}')
|
|
return false
|
|
}
|
|
|
|
if result.etag != result2.etag {
|
|
println(' Iteration ${i}: ETags differ for same content: ${result.etag} vs ${result2.etag}')
|
|
return false
|
|
}
|
|
|
|
storage.delete(test_bucket, filename) or {}
|
|
storage.delete(test_bucket, filename2) or {}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
fn main() {
|
|
println('🚀 开始 Upload-Download Round Trip 属性测试...')
|
|
println('Feature: v-hono-upload-integration, Property 1: Upload-Download Round Trip (Local)')
|
|
println('Validates: Requirements 1.2, 2.3')
|
|
println('每个属性测试运行 ${test_iterations} 次迭代\n')
|
|
|
|
rand.seed([u32(time.now().unix()), u32(54321)])
|
|
|
|
mut stats := PropertyTestStats{}
|
|
|
|
stats.run_property_test('Property 1.1: Basic Upload-Download Round Trip', test_property_1_1_basic_roundtrip)
|
|
stats.run_property_test('Property 1.2: Empty File Round Trip', test_property_1_2_empty_file_roundtrip)
|
|
stats.run_property_test('Property 1.3: Large File Round Trip', test_property_1_3_large_file_roundtrip)
|
|
stats.run_property_test('Property 1.4: Nested Path Round Trip', test_property_1_4_nested_path_roundtrip)
|
|
stats.run_property_test('Property 1.5: ETag Consistency', test_property_1_5_etag_consistency)
|
|
|
|
stats.print_summary()
|
|
|
|
cleanup_test_dir()
|
|
|
|
if stats.failed_tests > 0 {
|
|
exit(1)
|
|
}
|
|
}
|