Implement SetUploadChunkSizer and SetUploadCutoffer in the tests so fstests can exercise the resumable upload path with small chunk sizes.
1122 lines
33 KiB
Go
1122 lines
33 KiB
Go
package huaweidrive
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"mime"
|
||
"net/http"
|
||
"path"
|
||
"slices"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/rclone/rclone/backend/huaweidrive/api"
|
||
"github.com/rclone/rclone/fs"
|
||
"github.com/rclone/rclone/fs/config/configmap"
|
||
"github.com/rclone/rclone/fs/hash"
|
||
"github.com/rclone/rclone/fstest/fstests"
|
||
"github.com/rclone/rclone/lib/encoder"
|
||
)
|
||
|
||
// Limits on the chunk size accepted by the resumable upload API
|
||
const (
|
||
minChunkSize = 256 * fs.Kibi
|
||
maxChunkSize = 64 * fs.Mebi
|
||
)
|
||
|
||
// TestIntegration runs integration tests against the remote
|
||
func TestIntegration(t *testing.T) {
|
||
fstests.Run(t, &fstests.Opt{
|
||
RemoteName: "TestHuaweiDrive:",
|
||
NilObject: (*Object)(nil),
|
||
SkipBadWindowsCharacters: true,
|
||
ChunkedUpload: fstests.ChunkedUploadConfig{
|
||
MinChunkSize: minChunkSize,
|
||
MaxChunkSize: maxChunkSize,
|
||
CeilChunkSize: fstests.NextPowerOfTwo,
|
||
},
|
||
})
|
||
}
|
||
|
||
// SetUploadChunkSize changes the configured chunk size, returning the old value.
|
||
//
|
||
// It is only called by the integration tests while no transfer is in progress.
|
||
func (f *Fs) SetUploadChunkSize(cs fs.SizeSuffix) (fs.SizeSuffix, error) {
|
||
var old fs.SizeSuffix
|
||
old, f.opt.ChunkSize = f.opt.ChunkSize, cs
|
||
return old, nil
|
||
}
|
||
|
||
// SetUploadCutoff changes the configured upload cutoff, returning the old value.
|
||
//
|
||
// It is only called by the integration tests while no transfer is in progress.
|
||
func (f *Fs) SetUploadCutoff(cutoff fs.SizeSuffix) (fs.SizeSuffix, error) {
|
||
var old fs.SizeSuffix
|
||
old, f.opt.UploadCutoff = f.opt.UploadCutoff, cutoff
|
||
return old, nil
|
||
}
|
||
|
||
var (
|
||
_ fstests.SetUploadChunkSizer = (*Fs)(nil)
|
||
_ fstests.SetUploadCutoffer = (*Fs)(nil)
|
||
)
|
||
|
||
// TestNewFs tests the NewFs constructor
|
||
func TestNewFs(t *testing.T) {
|
||
ctx := context.Background()
|
||
|
||
// Test with empty config - should fail due to missing OAuth credentials
|
||
m := configmap.Simple{}
|
||
_, err := NewFs(ctx, "test", "", m)
|
||
if err == nil {
|
||
t.Fatal("expected error with empty config")
|
||
}
|
||
}
|
||
|
||
// TestFsName tests the filesystem name
|
||
func TestFsName(t *testing.T) {
|
||
f := &Fs{name: "test"}
|
||
if f.Name() != "test" {
|
||
t.Errorf("expected name 'test', got %q", f.Name())
|
||
}
|
||
}
|
||
|
||
// TestFsRoot tests the filesystem root
|
||
func TestFsRoot(t *testing.T) {
|
||
f := &Fs{root: "test/path"}
|
||
if f.Root() != "test/path" {
|
||
t.Errorf("expected root 'test/path', got %q", f.Root())
|
||
}
|
||
}
|
||
|
||
// TestFsString tests the filesystem string representation
|
||
func TestFsString(t *testing.T) {
|
||
f := &Fs{root: "test/path"}
|
||
expected := "Huawei Drive root 'test/path'"
|
||
if f.String() != expected {
|
||
t.Errorf("expected string %q, got %q", expected, f.String())
|
||
}
|
||
}
|
||
|
||
// TestFsPrecision tests the filesystem precision
|
||
func TestFsPrecision(t *testing.T) {
|
||
f := &Fs{}
|
||
precision := f.Precision()
|
||
expectedPrecision := fs.ModTimeNotSupported
|
||
if precision != expectedPrecision {
|
||
t.Errorf("expected precision %v (ModTimeNotSupported), got %v", expectedPrecision, precision)
|
||
}
|
||
}
|
||
|
||
// TestFsHashes tests the supported hash types
|
||
func TestFsHashes(t *testing.T) {
|
||
f := &Fs{}
|
||
hashes := f.Hashes()
|
||
if !hashes.Contains(hash.SHA256) {
|
||
t.Error("expected SHA256 hash support")
|
||
}
|
||
}
|
||
|
||
// TestModTimeSupport tests if the filesystem supports modification time preservation
|
||
func TestModTimeSupport(t *testing.T) {
|
||
f := &Fs{}
|
||
// Huawei Drive does NOT preserve original modification times
|
||
// Despite the API accepting editedTime/createdTime parameters,
|
||
// the server always overwrites them with current server timestamp
|
||
expectedPrecision := fs.ModTimeNotSupported
|
||
if f.Precision() != expectedPrecision {
|
||
t.Errorf("expected precision of %v to indicate no time preservation, got %v", expectedPrecision, f.Precision())
|
||
}
|
||
}
|
||
|
||
// TestTimeFormats tests time format handling for RFC 3339
|
||
func TestTimeFormats(t *testing.T) {
|
||
// Test that we can format time correctly for Huawei Drive API
|
||
testTime := time.Date(2023, 10, 15, 14, 30, 45, 0, time.UTC)
|
||
formatted := testTime.Format(time.RFC3339)
|
||
expected := "2023-10-15T14:30:45Z"
|
||
|
||
if formatted != expected {
|
||
t.Errorf("expected RFC3339 format %q, got %q", expected, formatted)
|
||
}
|
||
|
||
// Test parsing back
|
||
parsed, err := time.Parse(time.RFC3339, formatted)
|
||
if err != nil {
|
||
t.Errorf("failed to parse RFC3339 time: %v", err)
|
||
}
|
||
|
||
if !parsed.Equal(testTime) {
|
||
t.Errorf("parsed time %v does not match original %v", parsed, testTime)
|
||
}
|
||
}
|
||
|
||
// TestEncoding tests filename encoding for Huawei Drive restrictions
|
||
func TestEncoding(t *testing.T) {
|
||
// Create encoder with Huawei Drive restrictions
|
||
enc := encoder.MultiEncoder( //nolint:unconvert
|
||
encoder.Display |
|
||
encoder.EncodeBackSlash |
|
||
encoder.EncodeSlash |
|
||
encoder.EncodeInvalidUtf8 |
|
||
encoder.EncodeRightSpace |
|
||
encoder.EncodeLeftSpace |
|
||
encoder.EncodeLeftTilde |
|
||
encoder.EncodeRightPeriod |
|
||
encoder.EncodeLeftPeriod |
|
||
encoder.EncodeColon |
|
||
encoder.EncodePipe |
|
||
encoder.EncodeDoubleQuote |
|
||
encoder.EncodeLtGt |
|
||
encoder.EncodeQuestion |
|
||
encoder.EncodeAsterisk |
|
||
encoder.EncodeCtl |
|
||
encoder.EncodeDot)
|
||
|
||
// Test cases for problematic characters that should be encoded
|
||
testCases := []struct {
|
||
name string
|
||
input string
|
||
expected string
|
||
}{
|
||
{"angle_brackets", "file<>name", "file<>name"},
|
||
{"quotes", "file\"name", "file"name"},
|
||
{"pipe", "file|name", "file|name"},
|
||
{"colon", "file:name", "file:name"},
|
||
{"asterisk", "file*name", "file*name"},
|
||
{"question", "file?name", "file?name"},
|
||
{"backslash", "file\\name", "file\name"},
|
||
{"forward_slash", "file/name", "file/name"},
|
||
{"leading_space", " filename", "␠filename"},
|
||
{"trailing_space", "filename ", "filename␠"},
|
||
{"leading_dot", ".filename", ".filename"},
|
||
{"control_chars", "file\tname\ntest", "file␉name␊test"},
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
encoded := enc.FromStandardName(tc.input)
|
||
if encoded != tc.expected {
|
||
t.Errorf("encoding %q: expected %q, got %q", tc.input, tc.expected, encoded)
|
||
}
|
||
|
||
// Test decoding back - only for reversible encodings
|
||
// Some characters like forward slash and control chars are one-way encoded
|
||
decoded := enc.ToStandardName(encoded)
|
||
if tc.name != "forward_slash" && tc.name != "control_chars" {
|
||
if decoded != tc.input {
|
||
t.Errorf("decoding %q: expected %q, got %q", encoded, tc.input, decoded)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestFileNameEncoding tests that problematic characters are properly encoded
|
||
func TestFileNameEncoding(t *testing.T) {
|
||
// Create a mock Fs with default encoding options
|
||
opts := Options{}
|
||
// Set the default encoding from our config
|
||
opts.Enc = (encoder.Display |
|
||
encoder.EncodeBackSlash |
|
||
encoder.EncodeInvalidUtf8 |
|
||
encoder.EncodeRightSpace |
|
||
encoder.EncodeLeftSpace |
|
||
encoder.EncodeLeftTilde |
|
||
encoder.EncodeRightPeriod |
|
||
encoder.EncodeLeftPeriod |
|
||
encoder.EncodeColon |
|
||
encoder.EncodePipe |
|
||
encoder.EncodeDoubleQuote |
|
||
encoder.EncodeLtGt |
|
||
encoder.EncodeQuestion |
|
||
encoder.EncodeAsterisk |
|
||
encoder.EncodeCtl |
|
||
encoder.EncodeDot)
|
||
|
||
f := &Fs{opt: opts}
|
||
|
||
// Test problematic characters that Huawei Drive rejects
|
||
testCases := []struct {
|
||
input string
|
||
desc string
|
||
}{
|
||
{`file<name>.txt`, "angle brackets"},
|
||
{`file|name.txt`, "pipe character"},
|
||
{`file:name.txt`, "colon"},
|
||
{`file"name.txt`, "double quote"},
|
||
{`file*name.txt`, "asterisk"},
|
||
{`file?name.txt`, "question mark"},
|
||
{`file\name.txt`, "backslash"},
|
||
{` leading_space.txt`, "leading space"},
|
||
{`trailing_space.txt `, "trailing space"},
|
||
{`.leading_dot.txt`, "leading dot"},
|
||
{`trailing_dot.txt.`, "trailing dot"},
|
||
{`~leading_tilde.txt`, "leading tilde"},
|
||
{"file\x00name.txt", "control character"},
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
encoded := f.opt.Enc.FromStandardName(tc.input)
|
||
// The encoded name should be different from input (meaning it was encoded)
|
||
if encoded == tc.input {
|
||
t.Errorf("Expected %s (%q) to be encoded, but got same string", tc.desc, tc.input)
|
||
}
|
||
|
||
// Test that we can decode it back (skip control characters as they are not reversible)
|
||
decoded := f.opt.Enc.ToStandardName(encoded)
|
||
if tc.desc != "control character" && decoded != tc.input {
|
||
t.Errorf("Round-trip failed for %s: input=%q, encoded=%q, decoded=%q", tc.desc, tc.input, encoded, decoded)
|
||
}
|
||
|
||
t.Logf("✓ %s: %q → %q → %q", tc.desc, tc.input, encoded, decoded)
|
||
}
|
||
}
|
||
|
||
// TestQueryFilter tests the QueryFilter functionality
|
||
func TestQueryFilter(t *testing.T) {
|
||
t.Run("empty_filter", func(t *testing.T) {
|
||
filter := NewQueryFilter()
|
||
if filter.String() != "" {
|
||
t.Errorf("expected empty string, got %q", filter.String())
|
||
}
|
||
})
|
||
|
||
t.Run("single_condition", func(t *testing.T) {
|
||
filter := NewQueryFilter()
|
||
filter.AddParentFolder("test_parent_id")
|
||
expected := "'test_parent_id' in parentFolder"
|
||
if filter.String() != expected {
|
||
t.Errorf("expected %q, got %q", expected, filter.String())
|
||
}
|
||
})
|
||
|
||
t.Run("multiple_conditions", func(t *testing.T) {
|
||
filter := NewQueryFilter()
|
||
filter.AddParentFolder("parent_id")
|
||
filter.AddMimeType("image/jpeg")
|
||
filter.AddFavorite(true)
|
||
expected := "'parent_id' in parentFolder and mimeType = 'image/jpeg' and favorite = true"
|
||
if filter.String() != expected {
|
||
t.Errorf("expected %q, got %q", expected, filter.String())
|
||
}
|
||
})
|
||
|
||
t.Run("filename_with_quotes", func(t *testing.T) {
|
||
filter := NewQueryFilter()
|
||
filter.AddFileName("file'with'quotes.txt")
|
||
expected := "fileName = 'file\\'with\\'quotes.txt'"
|
||
if filter.String() != expected {
|
||
t.Errorf("expected %q, got %q", expected, filter.String())
|
||
}
|
||
})
|
||
|
||
t.Run("mime_type_not_equal", func(t *testing.T) {
|
||
filter := NewQueryFilter()
|
||
filter.AddMimeTypeNot("application/vnd.huawei-apps.folder")
|
||
expected := "mimeType != 'application/vnd.huawei-apps.folder'"
|
||
if filter.String() != expected {
|
||
t.Errorf("expected %q, got %q", expected, filter.String())
|
||
}
|
||
})
|
||
|
||
t.Run("filename_contains", func(t *testing.T) {
|
||
filter := NewQueryFilter()
|
||
filter.AddFileNameContains("test")
|
||
expected := "fileName contains 'test'"
|
||
if filter.String() != expected {
|
||
t.Errorf("expected %q, got %q", expected, filter.String())
|
||
}
|
||
})
|
||
|
||
t.Run("recycled_status", func(t *testing.T) {
|
||
filter := NewQueryFilter()
|
||
filter.AddRecycled(true)
|
||
filter.AddDirectlyRecycled(false)
|
||
expected := "recycled = true and directlyRecycled = false"
|
||
if filter.String() != expected {
|
||
t.Errorf("expected %q, got %q", expected, filter.String())
|
||
}
|
||
})
|
||
|
||
t.Run("edited_time_range", func(t *testing.T) {
|
||
filter := NewQueryFilter()
|
||
testTime := time.Date(2023, 10, 15, 14, 30, 45, 0, time.UTC)
|
||
filter.AddEditedTimeRange(">=", testTime)
|
||
expected := "editedTime >= '2023-10-15T14:30:45Z'"
|
||
if filter.String() != expected {
|
||
t.Errorf("expected %q, got %q", expected, filter.String())
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestAPITypes tests the API type structures and methods
|
||
func TestAPITypes(t *testing.T) {
|
||
t.Run("file_is_dir", func(t *testing.T) {
|
||
// Test folder
|
||
folder := api.File{MimeType: api.FolderMimeType}
|
||
if !folder.IsDir() {
|
||
t.Error("expected folder to be recognized as directory")
|
||
}
|
||
|
||
// Test regular file
|
||
file := api.File{MimeType: "text/plain"}
|
||
if file.IsDir() {
|
||
t.Error("expected regular file to not be recognized as directory")
|
||
}
|
||
})
|
||
|
||
t.Run("constants", func(t *testing.T) {
|
||
if api.FolderMimeType != "application/vnd.huawei-apps.folder" {
|
||
t.Errorf("unexpected folder mime type: %q", api.FolderMimeType)
|
||
}
|
||
|
||
if api.CategoryDriveFile != "drive#file" {
|
||
t.Errorf("unexpected drive file category: %q", api.CategoryDriveFile)
|
||
}
|
||
|
||
if api.CategoryDriveFileList != "drive#fileList" {
|
||
t.Errorf("unexpected drive file list category: %q", api.CategoryDriveFileList)
|
||
}
|
||
|
||
if api.UploadTypeMultipart != "multipart" {
|
||
t.Errorf("unexpected multipart upload type: %q", api.UploadTypeMultipart)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestErrorHandling tests error handling scenarios
|
||
func TestErrorHandling(t *testing.T) {
|
||
ctx := context.Background()
|
||
|
||
testCases := []struct {
|
||
name string
|
||
statusCode int
|
||
errorBody string
|
||
expectErr bool
|
||
errType string
|
||
}{
|
||
{"missing_param_400", 400, "21004001", true, "missing required parameters"},
|
||
{"invalid_param_400", 400, "PARAM_INVALID", true, "invalid parameters"},
|
||
{"parent_not_found_400", 400, "PARENTFOLDER_NOT_FOUND", true, "ErrorDirNotFound"},
|
||
{"insufficient_scope_403", 403, "INSUFFICIENT_SCOPE", true, "insufficient OAuth scope"},
|
||
{"insufficient_permission_403", 403, "INSUFFICIENT_PERMISSION", true, "insufficient permissions"},
|
||
{"agreement_not_signed_403", 403, "AGREEMENT_NOT_SIGNED", true, "user agreement not signed"},
|
||
{"data_migrating_403", 403, "DATA_MIGRATING", false, ""}, // Should allow retry
|
||
{"outer_service_error_403", 403, "OUTER_SERVICE_ERROR", false, ""}, // Should allow retry
|
||
{"cursor_expired_410", 410, "CURSOR_EXPIRED", true, "cursor expired"},
|
||
{"temp_data_cleared_410", 410, "TEMP_DATA_CLEARED", true, "temporary data cleared"},
|
||
{"server_temp_error_500", 500, "SERVER_TEMP_ERROR", false, ""}, // Should allow retry
|
||
{"outer_service_unavailable_500", 500, "OUTER_SERVICE_UNAVAILABLE", false, ""}, // Should allow retry
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
// Mock HTTP response
|
||
resp := &http.Response{
|
||
StatusCode: tc.statusCode,
|
||
}
|
||
|
||
// Mock error with specific error code
|
||
err := errors.New(tc.errorBody)
|
||
|
||
shouldRetryResult, resultErr := shouldRetry(ctx, resp, err)
|
||
|
||
if tc.expectErr {
|
||
// Should not retry and should return error
|
||
if shouldRetryResult {
|
||
t.Error("expected shouldRetry to return false for non-retryable error")
|
||
}
|
||
if resultErr == nil {
|
||
t.Error("expected error to be returned")
|
||
}
|
||
} else if !shouldRetryResult {
|
||
// Should retry (temporary error)
|
||
t.Error("expected shouldRetry to return true for retryable error")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestMimeTypeDetection tests MIME type detection for various file types
|
||
func TestMimeTypeDetection(t *testing.T) {
|
||
testCases := []struct {
|
||
filename string
|
||
expectedMimes []string // Allow multiple valid MIME types for cross-platform compatibility
|
||
}{
|
||
{"document.pdf", []string{"application/pdf"}},
|
||
{"image.jpg", []string{"image/jpeg"}},
|
||
{"image.jpeg", []string{"image/jpeg"}},
|
||
{"image.png", []string{"image/png"}},
|
||
{"image.gif", []string{"image/gif"}},
|
||
{"video.mp4", []string{"video/mp4"}},
|
||
{"video.avi", []string{"video/x-msvideo", "video/vnd.avi", "video/avi"}}, // Different systems may return different values
|
||
{"audio.mp3", []string{"audio/mpeg"}},
|
||
{"text.txt", []string{"text/plain; charset=utf-8"}},
|
||
{"data.json", []string{"application/json"}},
|
||
{"archive.zip", []string{"application/zip", "application/x-zip-compressed"}}, // Different systems may return different values
|
||
{"noextension", []string{"application/octet-stream"}},
|
||
{"", []string{"application/octet-stream"}},
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
t.Run(tc.filename, func(t *testing.T) {
|
||
detected := mime.TypeByExtension(path.Ext(tc.filename))
|
||
if detected == "" {
|
||
detected = "application/octet-stream"
|
||
}
|
||
|
||
// Check if detected MIME type is in the list of expected types
|
||
if !slices.Contains(tc.expectedMimes, detected) {
|
||
t.Errorf("expected one of MIME types %v for %q, got %q", tc.expectedMimes, tc.filename, detected)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestRootFolderID tests root folder ID handling
|
||
func TestRootFolderID(t *testing.T) {
|
||
f := &Fs{}
|
||
// Default root folder ID should be empty until detected
|
||
if f.rootFolderID != "" {
|
||
t.Errorf("expected empty root folder ID, got %q", f.rootFolderID)
|
||
}
|
||
|
||
// After setting, it should be accessible
|
||
f.rootFolderID = "test-root-id"
|
||
if f.rootFolderID != "test-root-id" {
|
||
t.Errorf("expected root folder ID %q, got %q", "test-root-id", f.rootFolderID)
|
||
}
|
||
}
|
||
|
||
// TestOptionsDefaults tests default option values
|
||
func TestOptionsDefaults(t *testing.T) {
|
||
opts := Options{}
|
||
|
||
// Check default values match what we expect
|
||
if opts.ChunkSize != 0 {
|
||
t.Errorf("expected default ChunkSize to be 0, got %v", opts.ChunkSize)
|
||
}
|
||
|
||
if opts.ListChunk != 0 {
|
||
t.Errorf("expected default ListChunk to be 0, got %v", opts.ListChunk)
|
||
}
|
||
|
||
if opts.UploadCutoff != 0 {
|
||
t.Errorf("expected default UploadCutoff to be 0, got %v", opts.UploadCutoff)
|
||
}
|
||
}
|
||
|
||
// TestParsePath tests path parsing functionality
|
||
func TestParsePath(t *testing.T) {
|
||
testCases := []struct {
|
||
input string
|
||
expected string
|
||
}{
|
||
{"", ""},
|
||
{"/", ""},
|
||
{"path", "path"},
|
||
{"/path", "path"},
|
||
{"path/", "path"},
|
||
{"/path/", "path"},
|
||
{"path/to/file", "path/to/file"},
|
||
{"/path/to/file", "path/to/file"},
|
||
{"path/to/file/", "path/to/file"},
|
||
{"/path/to/file/", "path/to/file"},
|
||
{"//multiple//slashes//", "multiple//slashes"},
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
t.Run(fmt.Sprintf("input_%q", tc.input), func(t *testing.T) {
|
||
result := parsePath(tc.input)
|
||
if result != tc.expected {
|
||
t.Errorf("parsePath(%q): expected %q, got %q", tc.input, tc.expected, result)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestConstants tests various constants and configuration values
|
||
func TestConstants(t *testing.T) {
|
||
// Test OAuth configuration
|
||
if rcloneClientID != "115505059" {
|
||
t.Errorf("expected client ID %q, got %q", "115505059", rcloneClientID)
|
||
}
|
||
|
||
// Test URLs
|
||
if rootURL != "https://driveapis.cloud.huawei.com.cn/drive/v1" {
|
||
t.Errorf("expected root URL %q, got %q", "https://driveapis.cloud.huawei.com.cn/drive/v1", rootURL)
|
||
}
|
||
|
||
if uploadURL != "https://driveapis.cloud.huawei.com.cn/upload/drive/v1" {
|
||
t.Errorf("expected upload URL %q, got %q", "https://driveapis.cloud.huawei.com.cn/upload/drive/v1", uploadURL)
|
||
}
|
||
|
||
// Test size limits
|
||
expectedChunkSize := 8 * fs.Mebi // 8MB
|
||
if defaultChunkSize != expectedChunkSize {
|
||
t.Errorf("expected default chunk size %d, got %d", expectedChunkSize, defaultChunkSize)
|
||
}
|
||
}
|
||
|
||
// TestFeatures tests the filesystem features configuration
|
||
func TestFeatures(t *testing.T) {
|
||
ctx := context.Background()
|
||
f := &Fs{}
|
||
|
||
// Create features manually since we can't call NewFs without proper config
|
||
features := (&fs.Features{
|
||
CaseInsensitive: false,
|
||
DuplicateFiles: false,
|
||
ReadMimeType: true,
|
||
WriteMimeType: true,
|
||
CanHaveEmptyDirectories: true,
|
||
BucketBased: false,
|
||
ReadMetadata: true,
|
||
WriteMetadata: true,
|
||
UserMetadata: true,
|
||
ReadDirMetadata: false,
|
||
WriteDirMetadata: false,
|
||
PartialUploads: false,
|
||
NoMultiThreading: false,
|
||
SlowModTime: false,
|
||
SlowHash: false,
|
||
}).Fill(ctx, f)
|
||
|
||
f.features = features
|
||
|
||
// Test that features are configured correctly for Huawei Drive
|
||
if f.features.CaseInsensitive {
|
||
t.Error("Huawei Drive should be case sensitive")
|
||
}
|
||
|
||
if f.features.DuplicateFiles {
|
||
t.Error("Huawei Drive should not allow duplicate files")
|
||
}
|
||
|
||
if !f.features.ReadMimeType {
|
||
t.Error("Huawei Drive should support reading MIME types")
|
||
}
|
||
|
||
if !f.features.WriteMimeType {
|
||
t.Error("Huawei Drive should support setting MIME types")
|
||
}
|
||
|
||
if !f.features.CanHaveEmptyDirectories {
|
||
t.Error("Huawei Drive should support empty directories")
|
||
}
|
||
|
||
if f.features.BucketBased {
|
||
t.Error("Huawei Drive is not bucket-based")
|
||
}
|
||
|
||
if !f.features.ReadMetadata {
|
||
t.Error("Huawei Drive should support reading metadata")
|
||
}
|
||
|
||
if !f.features.WriteMetadata {
|
||
t.Error("Huawei Drive should support writing metadata")
|
||
}
|
||
}
|
||
|
||
// TestAPIResponseParsing tests parsing of API responses
|
||
func TestAPIResponseParsing(t *testing.T) {
|
||
// Test About response parsing
|
||
t.Run("about_response", func(t *testing.T) {
|
||
about := api.About{
|
||
StorageQuota: struct {
|
||
UsedSpace string `json:"usedSpace"`
|
||
UserCapacity string `json:"userCapacity"`
|
||
}{
|
||
UsedSpace: "1073741824", // 1GB
|
||
UserCapacity: "10737418240", // 10GB
|
||
},
|
||
MaxThumbnailSize: 2097152, // 2MB
|
||
MaxFileUploadSize: "53687091200", // 50GB
|
||
Domain: "driveapis.cloud.huawei.com.cn",
|
||
Category: "drive#about",
|
||
User: api.User{
|
||
PermissionID: "test_permission_id",
|
||
DisplayName: "Test User",
|
||
Me: true,
|
||
Category: "drive#user",
|
||
},
|
||
}
|
||
|
||
// Test parsing
|
||
if about.StorageQuota.UsedSpace != "1073741824" {
|
||
t.Errorf("expected used space %q, got %q", "1073741824", about.StorageQuota.UsedSpace)
|
||
}
|
||
if about.User.Me != true {
|
||
t.Error("expected user.me to be true")
|
||
}
|
||
if about.Category != api.CategoryDriveAbout {
|
||
t.Errorf("expected category %q, got %q", api.CategoryDriveAbout, about.Category)
|
||
}
|
||
if about.MaxThumbnailSize != 2097152 {
|
||
t.Errorf("expected max thumbnail size 2097152, got %d", about.MaxThumbnailSize)
|
||
}
|
||
if about.MaxFileUploadSize != "53687091200" {
|
||
t.Errorf("expected max file upload size %q, got %q", "53687091200", about.MaxFileUploadSize)
|
||
}
|
||
if about.Domain != "driveapis.cloud.huawei.com.cn" {
|
||
t.Errorf("expected domain %q, got %q", "driveapis.cloud.huawei.com.cn", about.Domain)
|
||
}
|
||
})
|
||
|
||
// Test FileList response parsing
|
||
t.Run("filelist_response", func(t *testing.T) {
|
||
fileList := api.FileList{
|
||
Files: []api.File{
|
||
{
|
||
ID: "test_file_id",
|
||
FileName: "test_file.txt",
|
||
MimeType: "text/plain",
|
||
Category: api.CategoryDriveFile,
|
||
Size: 1024,
|
||
SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||
CreatedTime: time.Now(),
|
||
EditedTime: time.Now(),
|
||
ParentFolder: []string{"parent_folder_id"},
|
||
Owners: []api.User{
|
||
{
|
||
PermissionID: "owner_permission_id",
|
||
DisplayName: "Owner User",
|
||
Me: true,
|
||
Category: "drive#user",
|
||
},
|
||
},
|
||
OwnedByMe: true,
|
||
EditedByMe: true,
|
||
Recycled: false,
|
||
Favorite: false,
|
||
Containers: []string{"drive"},
|
||
},
|
||
},
|
||
NextPageToken: "next_page_token",
|
||
Category: api.CategoryDriveFileList,
|
||
}
|
||
|
||
// Test file list properties
|
||
if fileList.NextPageToken != "next_page_token" {
|
||
t.Errorf("expected next page token %q, got %q", "next_page_token", fileList.NextPageToken)
|
||
}
|
||
if fileList.Category != api.CategoryDriveFileList {
|
||
t.Errorf("expected category %q, got %q", api.CategoryDriveFileList, fileList.Category)
|
||
}
|
||
|
||
// Test file properties
|
||
if len(fileList.Files) != 1 {
|
||
t.Errorf("expected 1 file, got %d", len(fileList.Files))
|
||
}
|
||
|
||
file := fileList.Files[0]
|
||
if file.IsDir() {
|
||
t.Error("text file should not be recognized as directory")
|
||
}
|
||
if file.Size != 1024 {
|
||
t.Errorf("expected file size 1024, got %d", file.Size)
|
||
}
|
||
if !file.OwnedByMe {
|
||
t.Error("expected file to be owned by me")
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestObjectMetadata tests object metadata operations
|
||
func TestObjectMetadata(t *testing.T) {
|
||
// Create a mock object with metadata
|
||
obj := &Object{
|
||
fs: &Fs{},
|
||
remote: "test/file.txt",
|
||
hasMetaData: true,
|
||
size: 1024,
|
||
modTime: time.Now(),
|
||
id: "test_object_id",
|
||
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||
mimeType: "text/plain",
|
||
}
|
||
|
||
// Test basic object properties
|
||
if obj.Remote() != "test/file.txt" {
|
||
t.Errorf("expected remote %q, got %q", "test/file.txt", obj.Remote())
|
||
}
|
||
|
||
if obj.Size() != 1024 {
|
||
t.Errorf("expected size 1024, got %d", obj.Size())
|
||
}
|
||
|
||
// Test hash support
|
||
ctx := context.Background()
|
||
hash, err := obj.Hash(ctx, hash.SHA256)
|
||
if err != nil {
|
||
t.Errorf("unexpected error getting SHA256: %v", err)
|
||
}
|
||
if hash != obj.sha256 {
|
||
t.Errorf("expected hash %q, got %q", obj.sha256, hash)
|
||
}
|
||
|
||
// Test unsupported hash (only SHA256 is supported by Huawei Drive)
|
||
// We'll skip this test since it requires specific hash type constants
|
||
|
||
// Test storable
|
||
if !obj.Storable() {
|
||
t.Error("object should be storable")
|
||
}
|
||
}
|
||
|
||
// TestUploadTypes tests different upload type scenarios
|
||
func TestUploadTypes(t *testing.T) {
|
||
testCases := []struct {
|
||
name string
|
||
fileSize int64
|
||
cutoff fs.SizeSuffix
|
||
expectType string
|
||
}{
|
||
{"small_file_multipart", 1024, 20 * fs.Mebi, "multipart"},
|
||
{"large_file_resume", int64(100 * fs.Mebi), 20 * fs.Mebi, "resume"},
|
||
{"exact_cutoff_resume", int64(20 * fs.Mebi), 20 * fs.Mebi, "resume"},
|
||
{"zero_size_metadata", 0, 20 * fs.Mebi, "metadata"},
|
||
{"negative_size_resume", -1, 20 * fs.Mebi, "resume"}, // Unknown size
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
var actualType string
|
||
if tc.fileSize == 0 {
|
||
actualType = "metadata"
|
||
} else if tc.fileSize > 0 && tc.fileSize < int64(tc.cutoff) {
|
||
actualType = "multipart"
|
||
} else {
|
||
actualType = "resume"
|
||
}
|
||
|
||
if actualType != tc.expectType {
|
||
t.Errorf("expected upload type %q for size %d with cutoff %d, got %q",
|
||
tc.expectType, tc.fileSize, tc.cutoff, actualType)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestRetryErrorCodes tests the retry error codes list
|
||
func TestRetryErrorCodes(t *testing.T) {
|
||
expectedCodes := []int{429, 500, 502, 503, 504, 509}
|
||
|
||
if len(retryErrorCodes) != len(expectedCodes) {
|
||
t.Errorf("expected %d retry error codes, got %d", len(expectedCodes), len(retryErrorCodes))
|
||
}
|
||
|
||
for i, expected := range expectedCodes {
|
||
if i >= len(retryErrorCodes) || retryErrorCodes[i] != expected {
|
||
t.Errorf("expected retry error code %d at position %d, got %d", expected, i, retryErrorCodes[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestEncodingConfiguration tests the encoding configuration matches requirements
|
||
func TestEncodingConfiguration(t *testing.T) {
|
||
// This should match the default encoding set in the config
|
||
expectedEncoding := (encoder.Display |
|
||
encoder.EncodeBackSlash |
|
||
encoder.EncodeSlash |
|
||
encoder.EncodeInvalidUtf8 |
|
||
encoder.EncodeRightSpace |
|
||
encoder.EncodeLeftSpace |
|
||
encoder.EncodeLeftTilde |
|
||
encoder.EncodeRightPeriod |
|
||
encoder.EncodeLeftPeriod |
|
||
encoder.EncodeColon |
|
||
encoder.EncodePipe |
|
||
encoder.EncodeDoubleQuote |
|
||
encoder.EncodeLtGt |
|
||
encoder.EncodeQuestion |
|
||
encoder.EncodeAsterisk |
|
||
encoder.EncodeCtl |
|
||
encoder.EncodeDot)
|
||
|
||
// Test that all required encodings are present
|
||
requiredEncodings := []encoder.MultiEncoder{
|
||
encoder.EncodeBackSlash, // \
|
||
encoder.EncodeSlash, // /
|
||
encoder.EncodeColon, // :
|
||
encoder.EncodePipe, // |
|
||
encoder.EncodeDoubleQuote, // "
|
||
encoder.EncodeLtGt, // < >
|
||
encoder.EncodeQuestion, // ?
|
||
encoder.EncodeAsterisk, // *
|
||
encoder.EncodeCtl, // Control characters
|
||
}
|
||
|
||
for _, required := range requiredEncodings {
|
||
if (expectedEncoding & required) == 0 {
|
||
t.Errorf("encoding configuration missing required encoding: %v", required)
|
||
}
|
||
}
|
||
|
||
// Test that the encoding handles Huawei Drive restricted characters
|
||
enc := expectedEncoding
|
||
restrictedChars := []struct {
|
||
char string
|
||
desc string
|
||
}{
|
||
{"<", "less than"},
|
||
{">", "greater than"},
|
||
{"|", "pipe"},
|
||
{":", "colon"},
|
||
{"\"", "double quote"},
|
||
{"*", "asterisk"},
|
||
{"?", "question mark"},
|
||
{"/", "forward slash"},
|
||
{"\\", "backslash"},
|
||
}
|
||
|
||
for _, rc := range restrictedChars {
|
||
testName := "test" + rc.char + "file.txt"
|
||
encoded := enc.FromStandardName(testName)
|
||
if encoded == testName {
|
||
t.Errorf("character %s (%s) was not encoded", rc.char, rc.desc)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestScopeConfiguration tests OAuth scope configuration
|
||
func TestScopeConfiguration(t *testing.T) {
|
||
expectedScopes := []string{
|
||
"openid",
|
||
"profile",
|
||
"https://www.huawei.com/auth/drive",
|
||
"https://www.huawei.com/auth/drive.file",
|
||
}
|
||
|
||
if len(oauthConfig.Scopes) != len(expectedScopes) {
|
||
t.Errorf("expected %d OAuth scopes, got %d", len(expectedScopes), len(oauthConfig.Scopes))
|
||
}
|
||
|
||
for i, expected := range expectedScopes {
|
||
if i >= len(oauthConfig.Scopes) || oauthConfig.Scopes[i] != expected {
|
||
t.Errorf("expected OAuth scope %q at position %d, got %q", expected, i, oauthConfig.Scopes[i])
|
||
}
|
||
}
|
||
|
||
// Test OAuth URLs
|
||
expectedAuthURL := "https://oauth-login.cloud.huawei.com/oauth2/v3/authorize"
|
||
if oauthConfig.AuthURL != expectedAuthURL {
|
||
t.Errorf("expected auth URL %q, got %q", expectedAuthURL, oauthConfig.AuthURL)
|
||
}
|
||
|
||
expectedTokenURL := "https://oauth-login.cloud.huawei.com/oauth2/v3/token"
|
||
if oauthConfig.TokenURL != expectedTokenURL {
|
||
t.Errorf("expected token URL %q, got %q", expectedTokenURL, oauthConfig.TokenURL)
|
||
}
|
||
}
|
||
|
||
// TestObjectString tests object string representation
|
||
func TestObjectString(t *testing.T) {
|
||
// Test normal object
|
||
obj := &Object{remote: "path/to/file.txt"}
|
||
expected := "path/to/file.txt"
|
||
if obj.String() != expected {
|
||
t.Errorf("expected object string %q, got %q", expected, obj.String())
|
||
}
|
||
|
||
// Test nil object
|
||
var nilObj *Object
|
||
if nilObj.String() != "<nil>" {
|
||
t.Errorf("expected nil object string %q, got %q", "<nil>", nilObj.String())
|
||
}
|
||
}
|
||
|
||
// TestSetModTime tests setting modification time
|
||
func TestSetModTime(t *testing.T) {
|
||
// Huawei Drive does not support setting modification times
|
||
// SetModTime should always return fs.ErrorCantSetModTime
|
||
obj := &Object{id: "test-id"}
|
||
err := obj.SetModTime(context.Background(), time.Now())
|
||
if err != fs.ErrorCantSetModTime {
|
||
t.Errorf("SetModTime should return fs.ErrorCantSetModTime, got: %v", err)
|
||
}
|
||
} // TestCleanUp tests the CleanUp functionality
|
||
func TestCleanUp(t *testing.T) {
|
||
// Test that the interface is correctly implemented at compile time
|
||
// This test will fail to compile if CleanUpper interface is not properly implemented
|
||
var _ fs.CleanUpper = (*Fs)(nil)
|
||
|
||
t.Log("CleanUpper interface properly implemented")
|
||
}
|
||
|
||
// TestInterfaceCompliance tests all implemented interfaces at compile time
|
||
func TestInterfaceCompliance(t *testing.T) {
|
||
var _ fs.Fs = (*Fs)(nil)
|
||
var _ fs.Copier = (*Fs)(nil)
|
||
var _ fs.Mover = (*Fs)(nil)
|
||
var _ fs.DirMover = (*Fs)(nil)
|
||
var _ fs.ListRer = (*Fs)(nil)
|
||
var _ fs.Abouter = (*Fs)(nil)
|
||
var _ fs.Purger = (*Fs)(nil)
|
||
var _ fs.CleanUpper = (*Fs)(nil)
|
||
var _ fs.UserInfoer = (*Fs)(nil)
|
||
var _ fs.Disconnecter = (*Fs)(nil)
|
||
var _ fs.DirCacheFlusher = (*Fs)(nil)
|
||
var _ fs.MimeTyper = (*Object)(nil)
|
||
}
|
||
|
||
// TestMimeTyper tests the MimeType method on Object
|
||
func TestMimeTyper(t *testing.T) {
|
||
ctx := context.Background()
|
||
obj := &Object{
|
||
mimeType: "application/pdf",
|
||
hasMetaData: true,
|
||
}
|
||
|
||
if obj.MimeType(ctx) != "application/pdf" {
|
||
t.Errorf("expected MimeType %q, got %q", "application/pdf", obj.MimeType(ctx))
|
||
}
|
||
|
||
folderObj := &Object{
|
||
mimeType: api.FolderMimeType,
|
||
hasMetaData: true,
|
||
}
|
||
if folderObj.MimeType(ctx) != api.FolderMimeType {
|
||
t.Errorf("expected folder MimeType %q, got %q", api.FolderMimeType, folderObj.MimeType(ctx))
|
||
}
|
||
}
|
||
|
||
// TestRootFolderIDConfig tests the root_folder_id configuration option
|
||
func TestRootFolderIDConfig(t *testing.T) {
|
||
opts := Options{
|
||
RootFolderID: "test-root-folder-id",
|
||
}
|
||
|
||
if opts.RootFolderID != "test-root-folder-id" {
|
||
t.Errorf("expected RootFolderID %q, got %q", "test-root-folder-id", opts.RootFolderID)
|
||
}
|
||
|
||
emptyOpts := Options{}
|
||
if emptyOpts.RootFolderID != "" {
|
||
t.Errorf("expected empty default RootFolderID, got %q", emptyOpts.RootFolderID)
|
||
}
|
||
}
|
||
|
||
// TestGlobalDomains tests the global domain detection
|
||
func TestGlobalDomains(t *testing.T) {
|
||
if !api.GlobalDomains["driveapis.cloud.huawei.com.cn"] {
|
||
t.Error("driveapis.cloud.huawei.com.cn should be recognized as global domain")
|
||
}
|
||
if !api.GlobalDomains["drive.cloud.hicloud.com"] {
|
||
t.Error("drive.cloud.hicloud.com should be recognized as global domain")
|
||
}
|
||
if api.GlobalDomains["unknown.domain.com"] {
|
||
t.Error("unknown.domain.com should not be recognized as global domain")
|
||
}
|
||
}
|
||
|
||
// TestDomainToRootURL tests domain-to-URL mapping
|
||
func TestDomainToRootURL(t *testing.T) {
|
||
testCases := []struct {
|
||
domain string
|
||
expected string
|
||
}{
|
||
{"china", "https://drive-drcn.cloud.dbankcloud.cn"},
|
||
{"europe", "https://drive-dre.cloud.dbankcloud.cn"},
|
||
{"russia", "https://drive-drru.cloud.dbankcloud.cn"},
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
url, ok := api.DomainToRootURL[tc.domain]
|
||
if !ok {
|
||
t.Errorf("domain %q not found in DomainToRootURL", tc.domain)
|
||
continue
|
||
}
|
||
if url != tc.expected {
|
||
t.Errorf("domain %q: expected URL %q, got %q", tc.domain, tc.expected, url)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDetectRootIDAlgorithm tests the root ID detection algorithm logic
|
||
func TestDetectRootIDAlgorithm(t *testing.T) {
|
||
t.Run("root_not_in_file_ids", func(t *testing.T) {
|
||
files := []api.File{
|
||
{ID: "file1", ParentFolder: []string{"ROOT_ID"}},
|
||
{ID: "file2", ParentFolder: []string{"ROOT_ID"}},
|
||
{ID: "dir1", ParentFolder: []string{"ROOT_ID"}},
|
||
{ID: "file3", ParentFolder: []string{"dir1"}},
|
||
}
|
||
|
||
fileIDs := make(map[string]bool)
|
||
parentCounts := make(map[string]int)
|
||
for _, f := range files {
|
||
fileIDs[f.ID] = true
|
||
for _, p := range f.ParentFolder {
|
||
parentCounts[p]++
|
||
}
|
||
}
|
||
|
||
var bestID string
|
||
var bestCount int
|
||
for id, count := range parentCounts {
|
||
if !fileIDs[id] && count > bestCount {
|
||
bestID = id
|
||
bestCount = count
|
||
}
|
||
}
|
||
|
||
if bestID != "ROOT_ID" {
|
||
t.Errorf("expected ROOT_ID, got %q", bestID)
|
||
}
|
||
if bestCount != 3 {
|
||
t.Errorf("expected count 3, got %d", bestCount)
|
||
}
|
||
})
|
||
|
||
t.Run("all_parents_are_file_ids", func(t *testing.T) {
|
||
files := []api.File{
|
||
{ID: "dir_a", ParentFolder: []string{"dir_b"}},
|
||
{ID: "dir_b", ParentFolder: []string{"dir_a"}},
|
||
}
|
||
|
||
fileIDs := make(map[string]bool)
|
||
parentCounts := make(map[string]int)
|
||
for _, f := range files {
|
||
fileIDs[f.ID] = true
|
||
for _, p := range f.ParentFolder {
|
||
parentCounts[p]++
|
||
}
|
||
}
|
||
|
||
var bestID string
|
||
var bestCount int
|
||
for id, count := range parentCounts {
|
||
if !fileIDs[id] && count > bestCount {
|
||
bestID = id
|
||
bestCount = count
|
||
}
|
||
}
|
||
|
||
if bestID != "" {
|
||
t.Errorf("expected empty bestID from primary pass, got %q", bestID)
|
||
}
|
||
|
||
// Fallback
|
||
for id, count := range parentCounts {
|
||
if count > bestCount {
|
||
bestID = id
|
||
bestCount = count
|
||
}
|
||
}
|
||
|
||
if bestID == "" {
|
||
t.Error("expected fallback to find a parent ID")
|
||
}
|
||
})
|
||
|
||
t.Run("empty_file_list", func(t *testing.T) {
|
||
files := []api.File{}
|
||
if len(files) != 0 {
|
||
t.Error("expected empty file list")
|
||
}
|
||
})
|
||
}
|