Files
v-hono/tests/test_local_storage_roundtrip_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

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)
}
}