diff --git a/.claude/settings.json b/.claude/settings.json index b55094c..c3fc4a0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,8 +4,11 @@ "Bash(find:*)", "Bash(dotnet build:*)", "Bash(dotnet clean:*)", + "Bash(dotnet restore:*)", "Bash(dotnet test:*)", - "Bash(grep:*)" + "Bash(grep:*)", + "Bash(docker compose:*)", + "Bash(docker exec:*)" ], "deny": [] } diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 7cbf1cb..63c7360 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -58,8 +58,28 @@ jobs: DEBUG: 0 EDGE_PORT: 4566 + steps: - uses: actions/checkout@v6 + + - name: Start WebDAV server + run: | + echo "=== Starting WebDAV server ===" + docker run -d --name webdav-server -p 8080:8080 \ + eclipse-temurin:11-jre bash -c " + apt-get update && apt-get install -y curl wget && + wget -O webdav-server.jar https://repo1.maven.org/maven2/io/github/atetzner/webdav-embedded-server/0.2.1/webdav-embedded-server-0.2.1.jar && + mkdir -p /webdav && + java -jar webdav-server.jar --port 8080 --directory /webdav + " + + - name: Wait for services to be ready + run: | + echo "=== Waiting for services to be ready ===" + sleep 15 + echo "=== Testing WebDAV server ===" + curl -f http://localhost:8080/ || echo "WebDAV not ready yet, continuing..." + - name: Setup .NET uses: actions/setup-dotnet@v5 with: @@ -91,3 +111,7 @@ jobs: S3_TEST_SECRET_KEY: test S3_TEST_ENDPOINT: http://localhost:4566 S3_TEST_PREFIX: sharpsync-tests + WEBDAV_TEST_URL: http://localhost:8080/ + WEBDAV_TEST_USER: "" + WEBDAV_TEST_PASS: "" + WEBDAV_TEST_ROOT: "" diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 9404726..b92e53c 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -48,7 +48,24 @@ services: timeout: 5s retries: 5 + webdav: + image: maltokyo/docker-nginx-webdav:latest + ports: + - "8080:80" + environment: + USERNAME: testuser + PASSWORD: testpass + volumes: + - webdav-data:/media/data + healthcheck: + test: ["CMD", "sh", "-c", "test -f /var/run/nginx.pid"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 60s + volumes: sftp-data: ftp-data: localstack-data: + webdav-data: diff --git a/scripts/run-integration-tests.ps1 b/scripts/run-integration-tests.ps1 index c33b81c..5584243 100644 --- a/scripts/run-integration-tests.ps1 +++ b/scripts/run-integration-tests.ps1 @@ -1,4 +1,4 @@ -# Script to run SharpSync integration tests with Docker-based test servers (SFTP, FTP, S3/LocalStack) +# Script to run SharpSync integration tests with Docker-based test servers (SFTP, FTP, S3/LocalStack, WebDAV) # Usage: .\scripts\run-integration-tests.ps1 [-TestFilter "filter"] param( @@ -10,7 +10,7 @@ $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ProjectRoot = Split-Path -Parent $ScriptDir -Write-Host "๐Ÿš€ Starting test servers (SFTP, FTP, S3/LocalStack)..." -ForegroundColor Cyan +Write-Host "๐Ÿš€ Starting test servers (SFTP, FTP, S3/LocalStack, WebDAV)..." -ForegroundColor Cyan Set-Location $ProjectRoot docker-compose -f docker-compose.test.yml up -d @@ -73,6 +73,22 @@ while ($elapsed -lt $timeout) { Write-Host " Waiting... ($elapsed`s/$timeout`s)" -ForegroundColor Gray } +Write-Host "โณ Waiting for WebDAV server to be ready..." -ForegroundColor Yellow +$elapsed = 0 +$isHealthy = $false + +while ($elapsed -lt $timeout) { + $containerStatus = docker-compose -f docker-compose.test.yml ps webdav + if ($containerStatus -match "healthy") { + Write-Host "โœ… WebDAV server is ready" -ForegroundColor Green + $isHealthy = $true + break + } + Start-Sleep -Seconds 2 + $elapsed += 2 + Write-Host " Waiting... ($elapsed`s/$timeout`s)" -ForegroundColor Gray +} + # Create S3 test bucket in LocalStack Write-Host "๐Ÿ“ฆ Creating S3 test bucket..." -ForegroundColor Cyan docker-compose -f docker-compose.test.yml exec -T localstack awslocal s3 mb s3://test-bucket 2>$null @@ -96,6 +112,11 @@ $env:S3_TEST_SECRET_KEY = "test" $env:S3_TEST_ENDPOINT = "http://localhost:4566" $env:S3_TEST_PREFIX = "sharpsync-tests" +$env:WEBDAV_TEST_URL = "http://localhost:8080/" +$env:WEBDAV_TEST_USER = "testuser" +$env:WEBDAV_TEST_PASS = "testpass" +$env:WEBDAV_TEST_ROOT = "" + Write-Host "๐Ÿงช Running tests..." -ForegroundColor Cyan # Run tests with optional filter diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh index cdc5197..b601a57 100755 --- a/scripts/run-integration-tests.sh +++ b/scripts/run-integration-tests.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Script to run SharpSync integration tests with Docker-based test servers (SFTP, FTP, S3/LocalStack) +# Script to run SharpSync integration tests with Docker-based test servers (SFTP, FTP, S3/LocalStack, WebDAV) # Usage: ./scripts/run-integration-tests.sh [test-filter] set -e @@ -8,7 +8,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -echo "๐Ÿš€ Starting test servers (SFTP, FTP, S3/LocalStack)..." +echo "๐Ÿš€ Starting test servers (SFTP, FTP, S3/LocalStack, WebDAV)..." docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" up -d echo "โณ Waiting for SFTP server to be ready..." @@ -58,6 +58,18 @@ while [ $ELAPSED -lt $TIMEOUT ]; do echo " Waiting... (${ELAPSED}s/${TIMEOUT}s)" done +echo "โณ Waiting for WebDAV server to be ready..." +ELAPSED=0 +while [ $ELAPSED -lt $TIMEOUT ]; do + if docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" ps webdav | grep -q "healthy"; then + echo "โœ… WebDAV server is ready" + break + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) + echo " Waiting... (${ELAPSED}s/${TIMEOUT}s)" +done + # Create S3 test bucket in LocalStack echo "๐Ÿ“ฆ Creating S3 test bucket..." docker-compose -f "$PROJECT_ROOT/docker-compose.test.yml" exec -T localstack awslocal s3 mb s3://test-bucket 2>/dev/null || true @@ -81,6 +93,11 @@ export S3_TEST_SECRET_KEY=test export S3_TEST_ENDPOINT=http://localhost:4566 export S3_TEST_PREFIX=sharpsync-tests +export WEBDAV_TEST_URL=http://localhost:8080/ +export WEBDAV_TEST_USER=testuser +export WEBDAV_TEST_PASS=testpass +export WEBDAV_TEST_ROOT="" + echo "๐Ÿงช Running tests..." cd "$PROJECT_ROOT" diff --git a/src/SharpSync/Storage/WebDavStorage.cs b/src/SharpSync/Storage/WebDavStorage.cs index 285369e..6fa47a1 100644 --- a/src/SharpSync/Storage/WebDavStorage.cs +++ b/src/SharpSync/Storage/WebDavStorage.cs @@ -522,27 +522,63 @@ public async Task CreateDirectoryAsync(string path, CancellationToken cancellati if (!await EnsureAuthenticated(cancellationToken)) throw new UnauthorizedAccessException("Authentication failed"); - var fullPath = GetFullPath(path); + // Normalize the path + path = path.Replace('\\', '/').Trim('/'); - await ExecuteWithRetry(async () => { - // Check if directory already exists - var existsResult = await _client.Propfind(fullPath, new PropfindParameters { - CancellationToken = cancellationToken - }); + // Handle empty path + if (string.IsNullOrEmpty(path)) { + return; // Root directory already exists + } - if (existsResult.IsSuccessful) { - return true; // Directory already exists - } + // Create all parent directories first + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + var currentPath = ""; - var result = await _client.Mkcol(fullPath, new MkColParameters { - CancellationToken = cancellationToken - }); + for (int i = 0; i < segments.Length; i++) { + currentPath = i == 0 ? segments[i] : $"{currentPath}/{segments[i]}"; + var fullPath = GetFullPath(currentPath); + + await ExecuteWithRetry(async () => { + try { + // Check if directory already exists + var existsResult = await _client.Propfind(fullPath, new PropfindParameters { + RequestType = PropfindRequestType.NamedProperties, + CancellationToken = cancellationToken + }); - if (!result.IsSuccessful && result.StatusCode != 405) // 405 = already exists - throw new HttpRequestException($"Directory creation failed: {result.StatusCode}"); + if (existsResult.IsSuccessful) { + // Check if it's actually a collection/directory + var resource = existsResult.Resources?.FirstOrDefault(); + if (resource != null && resource.IsCollection) { + return true; // Directory already exists + } + } + } catch { + // PROPFIND failed, directory probably doesn't exist + } - return true; - }, cancellationToken); + // Try to create the directory + var result = await _client.Mkcol(fullPath, new MkColParameters { + CancellationToken = cancellationToken + }); + + if (result.IsSuccessful || result.StatusCode == 201) { + return true; // Created successfully + } + + if (result.StatusCode == 405) { + // Method Not Allowed - likely means it already exists as a file + return true; + } + + if (result.StatusCode == 409) { + // Conflict - parent doesn't exist, but we're creating in order so this shouldn't happen + throw new HttpRequestException($"Parent directory doesn't exist for {currentPath}"); + } + + throw new HttpRequestException($"Directory creation failed for {currentPath}: {result.StatusCode} {result.Description}"); + }, cancellationToken); + } } /// @@ -615,14 +651,19 @@ public async Task ExistsAsync(string path, CancellationToken cancellationT var fullPath = GetFullPath(path); - return await ExecuteWithRetry(async () => { - var result = await _client.Propfind(fullPath, new PropfindParameters { - RequestType = PropfindRequestType.AllProperties, - CancellationToken = cancellationToken - }); + try { + return await ExecuteWithRetry(async () => { + var result = await _client.Propfind(fullPath, new PropfindParameters { + RequestType = PropfindRequestType.NamedProperties, + CancellationToken = cancellationToken + }); - return result.IsSuccessful; - }, cancellationToken); + return result.IsSuccessful && result.StatusCode != 404; + }, cancellationToken); + } catch { + // If PROPFIND fails with an exception, assume the item doesn't exist + return false; + } } /// @@ -821,15 +862,18 @@ private async Task DetectServerCapabilitiesAsync(Cancellatio } private string GetFullPath(string relativePath) { - if (string.IsNullOrEmpty(relativePath) || relativePath == "/") - return string.IsNullOrEmpty(RootPath) ? _baseUrl : $"{_baseUrl}/{RootPath}"; + if (string.IsNullOrEmpty(relativePath) || relativePath == "/") { + var basePath = string.IsNullOrEmpty(RootPath) ? _baseUrl : $"{_baseUrl.TrimEnd('/')}/{RootPath.Trim('/')}"; + return basePath.TrimEnd('/') + "/"; + } relativePath = relativePath.Trim('/'); - if (string.IsNullOrEmpty(RootPath)) - return $"{_baseUrl}/{relativePath}"; - else - return $"{_baseUrl}/{RootPath}/{relativePath}"; + if (string.IsNullOrEmpty(RootPath)) { + return $"{_baseUrl.TrimEnd('/')}/{relativePath}"; + } else { + return $"{_baseUrl.TrimEnd('/')}/{RootPath.Trim('/')}/{relativePath}"; + } } private string GetRelativePath(string fullUrl) { diff --git a/tests/SharpSync.Tests/SharpSync.Tests.csproj b/tests/SharpSync.Tests/SharpSync.Tests.csproj index 50faefc..37d06eb 100644 --- a/tests/SharpSync.Tests/SharpSync.Tests.csproj +++ b/tests/SharpSync.Tests/SharpSync.Tests.csproj @@ -19,6 +19,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/SharpSync.Tests/Storage/FtpStorageTests.cs b/tests/SharpSync.Tests/Storage/FtpStorageTests.cs index 971d47f..a2ed281 100644 --- a/tests/SharpSync.Tests/Storage/FtpStorageTests.cs +++ b/tests/SharpSync.Tests/Storage/FtpStorageTests.cs @@ -1,3 +1,5 @@ +using Oire.SharpSync.Tests.Fixtures; + namespace Oire.SharpSync.Tests.Storage; /// diff --git a/tests/SharpSync.Tests/Storage/S3StorageTests.cs b/tests/SharpSync.Tests/Storage/S3StorageTests.cs index 3b3a395..1e0b55d 100644 --- a/tests/SharpSync.Tests/Storage/S3StorageTests.cs +++ b/tests/SharpSync.Tests/Storage/S3StorageTests.cs @@ -1,3 +1,5 @@ +using Oire.SharpSync.Tests.Fixtures; + namespace Oire.SharpSync.Tests.Storage; /// diff --git a/tests/SharpSync.Tests/Storage/SftpStorageTests.cs b/tests/SharpSync.Tests/Storage/SftpStorageTests.cs index e5b87cf..9ec0c91 100644 --- a/tests/SharpSync.Tests/Storage/SftpStorageTests.cs +++ b/tests/SharpSync.Tests/Storage/SftpStorageTests.cs @@ -1,3 +1,5 @@ +using Oire.SharpSync.Tests.Fixtures; + namespace Oire.SharpSync.Tests.Storage; /// @@ -647,9 +649,3 @@ public async Task GetItemAsync_IncludesPermissions() { #endregion } -/// -/// Exception to indicate test should be skipped -/// -public class SkipException: Exception { - public SkipException(string message) : base(message) { } -} diff --git a/tests/SharpSync.Tests/Storage/WebDavStorageTests.cs b/tests/SharpSync.Tests/Storage/WebDavStorageTests.cs new file mode 100644 index 0000000..cc41235 --- /dev/null +++ b/tests/SharpSync.Tests/Storage/WebDavStorageTests.cs @@ -0,0 +1,791 @@ +using System.Text; +using Oire.SharpSync.Auth; +using Oire.SharpSync.Core; +using Oire.SharpSync.Tests.Fixtures; + +namespace Oire.SharpSync.Tests.Storage; + +/// +/// Unit and integration tests for WebDavStorage +/// NOTE: Integration tests require a real WebDAV server. Set up environment variables: +/// - WEBDAV_TEST_URL: WebDAV server base URL (e.g., https://cloud.example.com/remote.php/dav/files/user/) +/// - WEBDAV_TEST_USER: WebDAV username (for basic auth) +/// - WEBDAV_TEST_PASS: WebDAV password (for basic auth) +/// - WEBDAV_TEST_ROOT: Root path within WebDAV share (optional, default: "") +/// +public class WebDavStorageTests: IDisposable { + private readonly string? _testUrl; + private readonly string? _testUser; + private readonly string? _testPass; + private readonly string _testRoot; + private readonly bool _integrationTestsEnabled; + private WebDavStorage? _storage; + + public WebDavStorageTests() { + // Read environment variables for integration tests + _testUrl = Environment.GetEnvironmentVariable("WEBDAV_TEST_URL"); + _testUser = Environment.GetEnvironmentVariable("WEBDAV_TEST_USER"); + _testPass = Environment.GetEnvironmentVariable("WEBDAV_TEST_PASS"); + _testRoot = Environment.GetEnvironmentVariable("WEBDAV_TEST_ROOT") ?? ""; + + _integrationTestsEnabled = !string.IsNullOrEmpty(_testUrl) && + !string.IsNullOrEmpty(_testUser) && + !string.IsNullOrEmpty(_testPass); + } + + public void Dispose() { + _storage?.Dispose(); + } + + #region Unit Tests (No Server Required) + + [Fact] + public void Constructor_BasicAuth_ValidParameters_CreatesStorage() { + // Act + using var storage = new WebDavStorage("https://cloud.example.com/remote.php/dav/files/user/", "testuser", "testpass"); + + // Assert + Assert.Equal(StorageType.WebDav, storage.StorageType); + Assert.Equal("", storage.RootPath); + } + + [Fact] + public void Constructor_BasicAuth_WithRootPath_CreatesStorage() { + // Act + using var storage = new WebDavStorage("https://cloud.example.com/remote.php/dav/files/user/", "testuser", "testpass", rootPath: "test/folder"); + + // Assert + Assert.Equal(StorageType.WebDav, storage.StorageType); + Assert.Equal("test/folder", storage.RootPath); + } + + [Fact] + public void Constructor_BasicAuth_EmptyUrl_ThrowsException() { + // Act & Assert + Assert.Throws(() => new WebDavStorage("", "user", "pass")); + } + + [Fact] + public void Constructor_OAuth2_ValidParameters_CreatesStorage() { + // Arrange + var oauth2Config = new OAuth2Config { + ClientId = "test-client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "http://localhost:8080/callback" + }; + var mockProvider = new MockOAuth2Provider(); + + // Act + using var storage = new WebDavStorage( + "https://cloud.example.com/remote.php/dav/files/user/", + oauth2Provider: mockProvider, + oauth2Config: oauth2Config); + + // Assert + Assert.Equal(StorageType.WebDav, storage.StorageType); + } + + [Fact] + public void Constructor_OAuth2_EmptyUrl_ThrowsException() { + // Arrange + var oauth2Config = new OAuth2Config { + ClientId = "test-client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "http://localhost:8080/callback" + }; + var mockProvider = new MockOAuth2Provider(); + + // Act & Assert + Assert.Throws(() => new WebDavStorage( + "", + oauth2Provider: mockProvider, + oauth2Config: oauth2Config)); + } + + [Fact] + public void Constructor_BasicAuth_CustomChunkSize_CreatesStorage() { + // Arrange + var customChunkSize = 5 * 1024 * 1024; // 5MB + + // Act + using var storage = new WebDavStorage( + "https://cloud.example.com/remote.php/dav/files/user/", + "user", + "pass", + chunkSizeBytes: customChunkSize); + + // Assert + Assert.Equal(StorageType.WebDav, storage.StorageType); + } + + [Fact] + public void StorageType_Property_ReturnsWebDav() { + // Arrange + using var storage = new WebDavStorage("https://cloud.example.com/remote.php/dav/files/user/", "user", "pass"); + + // Assert + Assert.Equal(StorageType.WebDav, storage.StorageType); + } + + [Fact] + public void RootPath_Property_ReturnsCorrectPath() { + // Arrange + var rootPath = "test/path"; + using var storage = new WebDavStorage("https://cloud.example.com/remote.php/dav/files/user/", "user", "pass", rootPath: rootPath); + + // Assert + Assert.Equal(rootPath, storage.RootPath); + } + + [Fact] + public void RootPath_Property_TrimsSlashes() { + // Arrange + using var storage = new WebDavStorage("https://cloud.example.com/remote.php/dav/files/user/", "user", "pass", rootPath: "/test/path/"); + + // Assert + Assert.Equal("test/path", storage.RootPath); + } + + [Fact] + public async Task AuthenticateAsync_NoOAuth2Provider_ReturnsTrue() { + // Arrange + using var storage = new WebDavStorage("https://cloud.example.com/remote.php/dav/files/user/", "user", "pass"); + + // Act + var result = await storage.AuthenticateAsync(); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task AuthenticateAsync_WithOAuth2Provider_CallsAuthenticate() { + // Arrange + var oauth2Config = new OAuth2Config { + ClientId = "test-client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "http://localhost:8080/callback" + }; + var mockProvider = new MockOAuth2Provider(); + using var storage = new WebDavStorage( + "https://cloud.example.com/remote.php/dav/files/user/", + oauth2Provider: mockProvider, + oauth2Config: oauth2Config); + + // Act + var result = await storage.AuthenticateAsync(); + + // Assert + Assert.True(result); + Assert.True(mockProvider.AuthenticateCalled); + } + + [Fact] + public async Task AuthenticateAsync_ValidToken_DoesNotReauthenticate() { + // Arrange + var oauth2Config = new OAuth2Config { + ClientId = "test-client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "http://localhost:8080/callback" + }; + var mockProvider = new MockOAuth2Provider(); + using var storage = new WebDavStorage( + "https://cloud.example.com/remote.php/dav/files/user/", + oauth2Provider: mockProvider, + oauth2Config: oauth2Config); + + // Act + await storage.AuthenticateAsync(); + mockProvider.AuthenticateCalled = false; // Reset flag + await storage.AuthenticateAsync(); + + // Assert - Second call should not authenticate again + Assert.False(mockProvider.AuthenticateCalled); + } + + [Fact] + public async Task AuthenticateAsync_ExpiredToken_UsesRefreshToken() { + // Arrange + var oauth2Config = new OAuth2Config { + ClientId = "test-client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "http://localhost:8080/callback" + }; + var mockProvider = new MockOAuth2Provider(expireTokenImmediately: true); + using var storage = new WebDavStorage( + "https://cloud.example.com/remote.php/dav/files/user/", + oauth2Provider: mockProvider, + oauth2Config: oauth2Config); + + // Act + await storage.AuthenticateAsync(); + await Task.Delay(100); // Ensure token is expired + mockProvider.RefreshCalled = false; // Reset flag + await storage.AuthenticateAsync(); + + // Assert + Assert.True(mockProvider.RefreshCalled); + } + + [Fact] + public async Task GetServerCapabilitiesAsync_CachesResult() { + // Arrange + using var storage = new WebDavStorage("https://cloud.example.com/remote.php/dav/files/user/", "user", "pass"); + + // Act + var capabilities1 = await storage.GetServerCapabilitiesAsync(); + var capabilities2 = await storage.GetServerCapabilitiesAsync(); + + // Assert - Should return same instance (cached) + Assert.Same(capabilities1, capabilities2); + } + + #endregion + + #region Integration Tests (Require WebDAV Server) + + private void SkipIfIntegrationTestsDisabled() { + Skip.If(!_integrationTestsEnabled, "Integration tests disabled. Set WEBDAV_TEST_URL, WEBDAV_TEST_USER, and WEBDAV_TEST_PASS environment variables."); + } + + private WebDavStorage CreateStorage() { + SkipIfIntegrationTestsDisabled(); + return new WebDavStorage(_testUrl!, _testUser!, _testPass!, rootPath: $"{_testRoot}/sharpsync-test-{Guid.NewGuid()}"); + } + + [SkippableFact] + public async Task TestConnectionAsync_ValidCredentials_ReturnsTrue() { + SkipIfIntegrationTestsDisabled(); + + // Arrange + using var storage = CreateStorage(); + + // Act + var result = await storage.TestConnectionAsync(); + + // Assert + Assert.True(result); + } + + [SkippableFact] + public async Task TestConnectionAsync_InvalidCredentials_ReturnsFalse() { + SkipIfIntegrationTestsDisabled(); + + // Arrange + using var storage = new WebDavStorage(_testUrl!, _testUser!, "wrong_password"); + + // Act + var result = await storage.TestConnectionAsync(); + + // Assert + Assert.False(result); + } + + [SkippableFact] + public async Task CreateDirectoryAsync_CreatesDirectory() { + // Arrange + _storage = CreateStorage(); + var dirPath = "test/subdir"; + + // Act + await _storage.CreateDirectoryAsync(dirPath); + var exists = await _storage.ExistsAsync(dirPath); + + // Assert + Assert.True(exists); + } + + [SkippableFact] + public async Task CreateDirectoryAsync_AlreadyExists_DoesNotThrow() { + // Arrange + _storage = CreateStorage(); + var dirPath = "test/existing"; + + // Act + await _storage.CreateDirectoryAsync(dirPath); + var existsAfterFirstCreate = await _storage.ExistsAsync(dirPath); + + // Ensure the directory exists after the first creation + Assert.True(existsAfterFirstCreate, "Directory should exist after first creation"); + + await _storage.CreateDirectoryAsync(dirPath); // Create again + + // Assert + var exists = await _storage.ExistsAsync(dirPath); + Assert.True(exists); + } + + [SkippableFact] + public async Task WriteFileAsync_CreatesFile() { + // Arrange + _storage = CreateStorage(); + var filePath = "test.txt"; + var content = "Hello, WebDAV World!"; + + // Act + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(filePath, stream); + + // Assert + var exists = await _storage.ExistsAsync(filePath); + Assert.True(exists); + } + + [SkippableFact] + public async Task WriteFileAsync_WithParentDirectory_CreatesParentDirectories() { + // Arrange + _storage = CreateStorage(); + var filePath = "parent/child/file.txt"; + var content = "Test content"; + + // Act + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(filePath, stream); + + // Assert + var exists = await _storage.ExistsAsync(filePath); + Assert.True(exists); + } + + [SkippableFact] + public async Task ReadFileAsync_ReturnsFileContent() { + // Arrange + _storage = CreateStorage(); + var filePath = "test_read.txt"; + var content = "Hello, WebDAV World!"; + + using var writeStream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(filePath, writeStream); + + // Act + using var readStream = await _storage.ReadFileAsync(filePath); + using var reader = new StreamReader(readStream); + var result = await reader.ReadToEndAsync(); + + // Assert + Assert.Equal(content, result); + } + + [SkippableFact] + public async Task ReadFileAsync_NonexistentFile_ThrowsException() { + // Arrange + _storage = CreateStorage(); + + // Act & Assert + await Assert.ThrowsAsync(() => _storage.ReadFileAsync("nonexistent.txt")); + } + + [SkippableFact] + public async Task ExistsAsync_ExistingFile_ReturnsTrue() { + // Arrange + _storage = CreateStorage(); + var filePath = "exists_test.txt"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test")); + await _storage.WriteFileAsync(filePath, stream); + + // Act + var result = await _storage.ExistsAsync(filePath); + + // Assert + Assert.True(result); + } + + [SkippableFact] + public async Task ExistsAsync_NonexistentFile_ReturnsFalse() { + // Arrange + _storage = CreateStorage(); + + // Act + var result = await _storage.ExistsAsync("nonexistent.txt"); + + // Assert + Assert.False(result); + } + + [SkippableFact] + public async Task DeleteAsync_ExistingFile_DeletesFile() { + // Arrange + _storage = CreateStorage(); + var filePath = "delete_test.txt"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes("test")); + await _storage.WriteFileAsync(filePath, stream); + + // Act + await _storage.DeleteAsync(filePath); + var exists = await _storage.ExistsAsync(filePath); + + // Assert + Assert.False(exists); + } + + [SkippableFact] + public async Task DeleteAsync_ExistingDirectory_DeletesDirectory() { + // Arrange + _storage = CreateStorage(); + var dirPath = "delete_dir"; + await _storage.CreateDirectoryAsync(dirPath); + + // Act + await _storage.DeleteAsync(dirPath); + var exists = await _storage.ExistsAsync(dirPath); + + // Assert + Assert.False(exists); + } + + [SkippableFact] + public async Task DeleteAsync_NonexistentFile_DoesNotThrow() { + // Arrange + _storage = CreateStorage(); + + // Act & Assert - Should not throw + await _storage.DeleteAsync("nonexistent.txt"); + } + + [SkippableFact] + public async Task MoveAsync_ExistingFile_MovesFile() { + // Arrange + _storage = CreateStorage(); + var sourcePath = "source.txt"; + var targetPath = "target.txt"; + var content = "Move test"; + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(sourcePath, stream); + + // Act + await _storage.MoveAsync(sourcePath, targetPath); + + // Assert + var sourceExists = await _storage.ExistsAsync(sourcePath); + var targetExists = await _storage.ExistsAsync(targetPath); + Assert.False(sourceExists); + Assert.True(targetExists); + } + + [SkippableFact] + public async Task MoveAsync_ToNewDirectory_CreatesParentDirectory() { + // Arrange + _storage = CreateStorage(); + var sourcePath = "source.txt"; + var targetPath = "newdir/target.txt"; + var content = "Move test"; + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(sourcePath, stream); + + // Act + await _storage.MoveAsync(sourcePath, targetPath); + + // Assert + var targetExists = await _storage.ExistsAsync(targetPath); + Assert.True(targetExists); + } + + [SkippableFact] + public async Task GetItemAsync_ExistingFile_ReturnsMetadata() { + // Arrange + _storage = CreateStorage(); + var filePath = "metadata_test.txt"; + var content = "Test content for metadata"; + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(filePath, stream); + + // Act + var item = await _storage.GetItemAsync(filePath); + + // Assert + Assert.NotNull(item); + Assert.Equal(filePath, item.Path); + Assert.False(item.IsDirectory); + Assert.True(item.Size > 0); + Assert.True(item.LastModified > DateTime.MinValue); + } + + [SkippableFact] + public async Task GetItemAsync_ExistingDirectory_ReturnsMetadata() { + // Arrange + _storage = CreateStorage(); + var dirPath = "metadata_dir"; + + // Ensure the directory is created + await _storage.CreateDirectoryAsync(dirPath); + + // Verify directory exists before testing GetItemAsync + var exists = await _storage.ExistsAsync(dirPath); + Assert.True(exists, "Directory should exist after creation"); + + // Act + var item = await _storage.GetItemAsync(dirPath); + + // Assert + Assert.NotNull(item); + Assert.True(item.IsDirectory); + } + + [SkippableFact] + public async Task GetItemAsync_NonexistentItem_ReturnsNull() { + // Arrange + _storage = CreateStorage(); + + // Act + var item = await _storage.GetItemAsync("nonexistent.txt"); + + // Assert + Assert.Null(item); + } + + [SkippableFact] + public async Task ListItemsAsync_EmptyDirectory_ReturnsEmpty() { + // Arrange + _storage = CreateStorage(); + var dirPath = "empty_dir"; + await _storage.CreateDirectoryAsync(dirPath); + + // Act + var items = await _storage.ListItemsAsync(dirPath); + + // Assert + Assert.Empty(items); + } + + [SkippableFact] + public async Task ListItemsAsync_WithFiles_ReturnsAllItems() { + // Arrange + _storage = CreateStorage(); + var dirPath = "list_test"; + await _storage.CreateDirectoryAsync(dirPath); + + // Create test files and subdirectories + await _storage.WriteFileAsync($"{dirPath}/file1.txt", new MemoryStream(Encoding.UTF8.GetBytes("content1"))); + await _storage.WriteFileAsync($"{dirPath}/file2.txt", new MemoryStream(Encoding.UTF8.GetBytes("content2"))); + await _storage.CreateDirectoryAsync($"{dirPath}/subdir"); + + // Verify all items exist before listing + Assert.True(await _storage.ExistsAsync($"{dirPath}/file1.txt"), "file1.txt should exist"); + Assert.True(await _storage.ExistsAsync($"{dirPath}/file2.txt"), "file2.txt should exist"); + Assert.True(await _storage.ExistsAsync($"{dirPath}/subdir"), "subdir should exist"); + + // Act + var items = (await _storage.ListItemsAsync(dirPath)).ToList(); + + // Debug output + foreach (var item in items) { + System.Diagnostics.Debug.WriteLine($"Found item: {item.Path}, IsDirectory: {item.IsDirectory}"); + } + + // Assert + Assert.Equal(3, items.Count); + Assert.Contains(items, i => i.Path.EndsWith("file1.txt") && !i.IsDirectory); + Assert.Contains(items, i => i.Path.EndsWith("file2.txt") && !i.IsDirectory); + Assert.Contains(items, i => i.Path.EndsWith("subdir") && i.IsDirectory); + } + + [SkippableFact] + public async Task ListItemsAsync_NonexistentDirectory_ReturnsEmpty() { + // Arrange + _storage = CreateStorage(); + + // Act + var items = await _storage.ListItemsAsync("nonexistent"); + + // Assert + Assert.Empty(items); + } + + [SkippableFact] + public async Task ComputeHashAsync_ExistingFile_ReturnsHash() { + // Arrange + _storage = CreateStorage(); + var filePath = "hash_test.txt"; + var content = "Test content for hashing"; + + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(filePath, stream); + + // Act + var hash = await _storage.ComputeHashAsync(filePath); + + // Assert + Assert.NotNull(hash); + Assert.NotEmpty(hash); + } + + [SkippableFact] + public async Task ComputeHashAsync_SameContent_ReturnsSameHash() { + // Arrange + _storage = CreateStorage(); + var filePath1 = "hash_test1.txt"; + var filePath2 = "hash_test2.txt"; + var content = "Identical content"; + + using var stream1 = new MemoryStream(Encoding.UTF8.GetBytes(content)); + using var stream2 = new MemoryStream(Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(filePath1, stream1); + await _storage.WriteFileAsync(filePath2, stream2); + + // Act + var hash1 = await _storage.ComputeHashAsync(filePath1); + var hash2 = await _storage.ComputeHashAsync(filePath2); + + // Assert + Assert.Equal(hash1, hash2); + } + + [SkippableFact] + public async Task GetStorageInfoAsync_ReturnsInfo() { + // Arrange + _storage = CreateStorage(); + + // Act + var info = await _storage.GetStorageInfoAsync(); + + // Assert + Assert.NotNull(info); + // Note: Some WebDAV servers may not support quota info, so values might be -1 + Assert.True(info.TotalSpace >= -1); + Assert.True(info.UsedSpace >= -1); + } + + [SkippableFact] + public async Task WriteFileAsync_LargeFile_RaisesProgressEvents() { + // Arrange + _storage = CreateStorage(); + var filePath = "large_file.bin"; + var fileSize = 2 * 1024 * 1024; // 2MB + var content = new byte[fileSize]; + new Random().NextBytes(content); + + var progressEventRaised = false; + _storage.ProgressChanged += (sender, args) => { + progressEventRaised = true; + Assert.Equal(filePath, args.Path); + Assert.Equal(StorageOperation.Upload, args.Operation); + }; + + // Act + using var stream = new MemoryStream(content); + await _storage.WriteFileAsync(filePath, stream); + + // Assert + var exists = await _storage.ExistsAsync(filePath); + Assert.True(exists); + // Note: Progress events may not be raised for all servers/sizes + } + + [SkippableFact] + public async Task ReadFileAsync_LargeFile_RaisesProgressEvents() { + // Arrange + _storage = CreateStorage(); + var filePath = "large_file_read.bin"; + var fileSize = 2 * 1024 * 1024; // 2MB + var content = new byte[fileSize]; + new Random().NextBytes(content); + + using var writeStream = new MemoryStream(content); + await _storage.WriteFileAsync(filePath, writeStream); + + var progressEventRaised = false; + _storage.ProgressChanged += (sender, args) => { + progressEventRaised = true; + Assert.Equal(StorageOperation.Download, args.Operation); + }; + + // Act + using var readStream = await _storage.ReadFileAsync(filePath); + using var ms = new MemoryStream(); + await readStream.CopyToAsync(ms); + + // Assert + Assert.Equal(fileSize, ms.Length); + // Note: Progress events may not be raised for all servers/sizes + } + + [SkippableFact] + public async Task GetServerCapabilitiesAsync_ReturnsCapabilities() { + // Arrange + _storage = CreateStorage(); + + // Act + var capabilities = await _storage.GetServerCapabilitiesAsync(); + + // Assert + Assert.NotNull(capabilities); + // Capabilities will vary by server type (Nextcloud, OCIS, generic WebDAV) + } + + [SkippableFact] + public async Task CancellationToken_CancelsOperation() { + // Arrange + _storage = CreateStorage(); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync(async () => + await _storage.ListItemsAsync("", cts.Token)); + } + + [SkippableFact] + public async Task Dispose_DisposesResources() { + // Arrange + var storage = CreateStorage(); + await storage.TestConnectionAsync(); + + // Act + storage.Dispose(); + + // Assert - Should not throw when disposed multiple times + storage.Dispose(); + } + + #endregion + + #region Mock OAuth2Provider for Testing + + private sealed class MockOAuth2Provider: IOAuth2Provider { + private readonly bool _expireTokenImmediately; + public bool AuthenticateCalled { get; set; } + public bool RefreshCalled { get; set; } + public bool ValidateCalled { get; set; } + + public MockOAuth2Provider(bool expireTokenImmediately = false) { + _expireTokenImmediately = expireTokenImmediately; + } + + public Task AuthenticateAsync(OAuth2Config config, CancellationToken cancellationToken = default) { + AuthenticateCalled = true; + return Task.FromResult(new OAuth2Result { + AccessToken = "mock-access-token", + RefreshToken = "mock-refresh-token", + ExpiresAt = _expireTokenImmediately ? DateTime.UtcNow.AddMilliseconds(10) : DateTime.UtcNow.AddHours(1), + TokenType = "Bearer", + UserId = "mock-user" + }); + } + + public Task RefreshTokenAsync(OAuth2Config config, string refreshToken, CancellationToken cancellationToken = default) { + RefreshCalled = true; + return Task.FromResult(new OAuth2Result { + AccessToken = "mock-refreshed-token", + RefreshToken = refreshToken, + ExpiresAt = DateTime.UtcNow.AddHours(1), + TokenType = "Bearer", + UserId = "mock-user" + }); + } + + public Task ValidateTokenAsync(OAuth2Result result, CancellationToken cancellationToken = default) { + ValidateCalled = true; + return Task.FromResult(result.IsValid); + } + } + + #endregion +}