diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..41bbacf --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet clean:*)", + "Bash(dotnet test:*)", + "Bash(grep:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/src/SharpSync/Core/SmartConflictResolver.cs b/src/SharpSync/Core/SmartConflictResolver.cs index f9d0835..3cb62dc 100644 --- a/src/SharpSync/Core/SmartConflictResolver.cs +++ b/src/SharpSync/Core/SmartConflictResolver.cs @@ -44,6 +44,8 @@ public async Task ResolveConflictAsync(FileConflictEventArgs /// Analyzes a conflict to provide rich information for decision making /// private static async Task AnalyzeConflictAsync(FileConflictEventArgs conflict, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + // Collect analysis data long localSize = 0; long remoteSize = 0; diff --git a/src/SharpSync/Sync/SyncEngine.cs b/src/SharpSync/Sync/SyncEngine.cs index bd14bf3..43244d5 100644 --- a/src/SharpSync/Sync/SyncEngine.cs +++ b/src/SharpSync/Sync/SyncEngine.cs @@ -205,9 +205,56 @@ private async Task DetectChangesAsync(SyncOptions? options, Cancellat } } + // Handle DeleteExtraneous option - delete files that exist on remote but not locally + if (options?.DeleteExtraneous == true) { + await DetectExtraneousFilesAsync(changeSet, cancellationToken); + } + return changeSet; } + /// + /// Detects files that exist on remote but not locally (extraneous files) and marks them for deletion + /// + private async Task DetectExtraneousFilesAsync(ChangeSet changeSet, CancellationToken cancellationToken) { + // Collect all local paths (both files and directories) + var localPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var addition in changeSet.Additions.Where(a => a.IsLocal)) { + localPaths.Add(addition.Path); + } + foreach (var modification in changeSet.Modifications.Where(m => m.IsLocal)) { + localPaths.Add(modification.Path); + } + + // Check all remote items to see if they exist locally + foreach (var addition in changeSet.Additions.Where(a => !a.IsLocal).ToList()) { + if (!localPaths.Contains(addition.Path)) { + // Remote file exists but no corresponding local file - mark for deletion + changeSet.Additions.Remove(addition); + changeSet.Deletions.Add(new DeletionChange { + Path = addition.Path, + DeletedLocally = true, + DeletedRemotely = false, + TrackedState = new SyncState { Path = addition.Path, Status = SyncStatus.RemoteNew } + }); + } + } + + // Check remote modifications too + foreach (var modification in changeSet.Modifications.Where(m => !m.IsLocal).ToList()) { + if (!localPaths.Contains(modification.Path) && !await _localStorage.ExistsAsync(modification.Path, cancellationToken)) { + // Remote file modified but no local file - mark for deletion + changeSet.Modifications.Remove(modification); + changeSet.Deletions.Add(new DeletionChange { + Path = modification.Path, + DeletedLocally = true, + DeletedRemotely = false, + TrackedState = modification.TrackedState + }); + } + } + } + /// /// Scans storage for changes compared to tracked state /// @@ -766,7 +813,7 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult IsDirectory = conflictItem.IsDirectory, Status = SyncStatus.LocalNew, LastSyncTime = DateTime.UtcNow, - LocalHash = conflictItem.ETag ?? await _localStorage.ComputeHashAsync(conflictPath, cancellationToken), + LocalHash = conflictItem.IsDirectory ? null : (conflictItem.ETag ?? await _localStorage.ComputeHashAsync(conflictPath, cancellationToken)), LocalSize = conflictItem.Size, LocalModified = conflictItem.LastModified }; @@ -796,7 +843,7 @@ private async Task ResolveConflictAsync(SyncAction action, ThreadSafeSyncResult IsDirectory = conflictItem.IsDirectory, Status = SyncStatus.RemoteNew, LastSyncTime = DateTime.UtcNow, - RemoteHash = conflictItem.ETag ?? await _remoteStorage.ComputeHashAsync(conflictPath, cancellationToken), + RemoteHash = conflictItem.IsDirectory ? null : (conflictItem.ETag ?? await _remoteStorage.ComputeHashAsync(conflictPath, cancellationToken)), RemoteSize = conflictItem.Size, RemoteModified = conflictItem.LastModified }; @@ -881,14 +928,14 @@ private async Task UpdateDatabaseStateAsync(ChangeSet changes, CancellationToken }; if (addition.IsLocal) { - state.LocalHash = addition.Item.ETag ?? await _localStorage.ComputeHashAsync(addition.Path, cancellationToken); + state.LocalHash = addition.Item.IsDirectory ? null : (addition.Item.ETag ?? await _localStorage.ComputeHashAsync(addition.Path, cancellationToken)); state.LocalSize = addition.Item.Size; state.LocalModified = addition.Item.LastModified; state.RemoteHash = state.LocalHash; state.RemoteSize = state.LocalSize; state.RemoteModified = state.LocalModified; } else { - state.RemoteHash = addition.Item.ETag ?? await _remoteStorage.ComputeHashAsync(addition.Path, cancellationToken); + state.RemoteHash = addition.Item.IsDirectory ? null : (addition.Item.ETag ?? await _remoteStorage.ComputeHashAsync(addition.Path, cancellationToken)); state.RemoteSize = addition.Item.Size; state.RemoteModified = addition.Item.LastModified; state.LocalHash = state.RemoteHash; @@ -906,14 +953,14 @@ private async Task UpdateDatabaseStateAsync(ChangeSet changes, CancellationToken state.LastSyncTime = DateTime.UtcNow; if (mod.IsLocal) { - state.LocalHash = mod.Item.ETag ?? await _localStorage.ComputeHashAsync(mod.Path, cancellationToken); + state.LocalHash = mod.Item.IsDirectory ? null : (mod.Item.ETag ?? await _localStorage.ComputeHashAsync(mod.Path, cancellationToken)); state.LocalSize = mod.Item.Size; state.LocalModified = mod.Item.LastModified; state.RemoteHash = state.LocalHash; state.RemoteSize = state.LocalSize; state.RemoteModified = state.LocalModified; } else { - state.RemoteHash = mod.Item.ETag ?? await _remoteStorage.ComputeHashAsync(mod.Path, cancellationToken); + state.RemoteHash = mod.Item.IsDirectory ? null : (mod.Item.ETag ?? await _remoteStorage.ComputeHashAsync(mod.Path, cancellationToken)); state.RemoteSize = mod.Item.Size; state.RemoteModified = mod.Item.LastModified; state.LocalHash = state.RemoteHash; diff --git a/tests/SharpSync.Tests/Auth/OAuth2ConfigTests.cs b/tests/SharpSync.Tests/Auth/OAuth2ConfigTests.cs new file mode 100644 index 0000000..76c2c6f --- /dev/null +++ b/tests/SharpSync.Tests/Auth/OAuth2ConfigTests.cs @@ -0,0 +1,368 @@ +using Oire.SharpSync.Auth; + +namespace Oire.SharpSync.Tests.Auth; + +public class OAuth2ConfigTests { + [Fact] + public void Constructor_RequiredProperties_InitializesCorrectly() { + // Arrange + var clientId = "test_client_id"; + var authorizeUrl = "https://example.com/oauth/authorize"; + var tokenUrl = "https://example.com/oauth/token"; + var redirectUri = "https://app.example.com/callback"; + + // Act + var config = new OAuth2Config { + ClientId = clientId, + AuthorizeUrl = authorizeUrl, + TokenUrl = tokenUrl, + RedirectUri = redirectUri + }; + + // Assert + Assert.Equal(clientId, config.ClientId); + Assert.Equal(authorizeUrl, config.AuthorizeUrl); + Assert.Equal(tokenUrl, config.TokenUrl); + Assert.Equal(redirectUri, config.RedirectUri); + } + + [Fact] + public void Constructor_AllProperties_InitializesCorrectly() { + // Arrange + var clientId = "test_client_id"; + var clientSecret = "test_client_secret"; + var authorizeUrl = "https://example.com/oauth/authorize"; + var tokenUrl = "https://example.com/oauth/token"; + var redirectUri = "https://app.example.com/callback"; + var scopes = new[] { "read", "write" }; + var additionalParams = new Dictionary { + { "response_type", "code" }, + { "custom_param", "value" } + }; + + // Act + var config = new OAuth2Config { + ClientId = clientId, + ClientSecret = clientSecret, + AuthorizeUrl = authorizeUrl, + TokenUrl = tokenUrl, + RedirectUri = redirectUri, + Scopes = scopes, + AdditionalParameters = additionalParams + }; + + // Assert + Assert.Equal(clientId, config.ClientId); + Assert.Equal(clientSecret, config.ClientSecret); + Assert.Equal(authorizeUrl, config.AuthorizeUrl); + Assert.Equal(tokenUrl, config.TokenUrl); + Assert.Equal(redirectUri, config.RedirectUri); + Assert.Equal(scopes, config.Scopes); + Assert.Equal(additionalParams, config.AdditionalParameters); + } + + [Fact] + public void Scopes_DefaultValue_IsEmpty() { + // Arrange & Act + var config = new OAuth2Config { + ClientId = "test_client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "https://app.example.com/callback" + }; + + // Assert + Assert.Empty(config.Scopes); + } + + [Fact] + public void AdditionalParameters_DefaultValue_IsEmpty() { + // Arrange & Act + var config = new OAuth2Config { + ClientId = "test_client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "https://app.example.com/callback" + }; + + // Assert + Assert.Empty(config.AdditionalParameters); + } + + [Fact] + public void ClientSecret_Null_IsAllowed() { + // Arrange & Act + var config = new OAuth2Config { + ClientId = "test_client", + ClientSecret = null, + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "https://app.example.com/callback" + }; + + // Assert + Assert.Null(config.ClientSecret); + } + + [Fact] + public void ForNextcloud_CreatesValidConfiguration() { + // Arrange + var serverUrl = "https://cloud.example.com"; + var clientId = "nextcloud_client"; + var redirectUri = "https://app.example.com/callback"; + + // Act + var config = OAuth2Config.ForNextcloud(serverUrl, clientId, redirectUri); + + // Assert + Assert.Equal(clientId, config.ClientId); + Assert.Equal("https://cloud.example.com/apps/oauth2/authorize", config.AuthorizeUrl); + Assert.Equal("https://cloud.example.com/apps/oauth2/api/v1/token", config.TokenUrl); + Assert.Equal(redirectUri, config.RedirectUri); + Assert.Single(config.Scopes); + Assert.Contains("files", config.Scopes); + Assert.Contains("response_type", config.AdditionalParameters.Keys); + Assert.Equal("code", config.AdditionalParameters["response_type"]); + } + + [Fact] + public void ForNextcloud_WithTrailingSlash_TrimsCorrectly() { + // Arrange + var serverUrl = "https://cloud.example.com/"; + var clientId = "nextcloud_client"; + var redirectUri = "https://app.example.com/callback"; + + // Act + var config = OAuth2Config.ForNextcloud(serverUrl, clientId, redirectUri); + + // Assert + Assert.Equal("https://cloud.example.com/apps/oauth2/authorize", config.AuthorizeUrl); + Assert.Equal("https://cloud.example.com/apps/oauth2/api/v1/token", config.TokenUrl); + } + + [Fact] + public void ForNextcloud_WithoutTrailingSlash_WorksCorrectly() { + // Arrange + var serverUrl = "https://cloud.example.com"; + var clientId = "nextcloud_client"; + var redirectUri = "https://app.example.com/callback"; + + // Act + var config = OAuth2Config.ForNextcloud(serverUrl, clientId, redirectUri); + + // Assert + Assert.Equal("https://cloud.example.com/apps/oauth2/authorize", config.AuthorizeUrl); + Assert.Equal("https://cloud.example.com/apps/oauth2/api/v1/token", config.TokenUrl); + } + + [Fact] + public void ForOcis_CreatesValidConfiguration() { + // Arrange + var serverUrl = "https://ocis.example.com"; + var clientId = "ocis_client"; + var redirectUri = "https://app.example.com/callback"; + + // Act + var config = OAuth2Config.ForOcis(serverUrl, clientId, redirectUri); + + // Assert + Assert.Equal(clientId, config.ClientId); + Assert.Equal("https://ocis.example.com/oauth2/auth", config.AuthorizeUrl); + Assert.Equal("https://ocis.example.com/oauth2/token", config.TokenUrl); + Assert.Equal(redirectUri, config.RedirectUri); + Assert.Equal(3, config.Scopes.Length); + Assert.Contains("openid", config.Scopes); + Assert.Contains("profile", config.Scopes); + Assert.Contains("offline_access", config.Scopes); + Assert.Contains("response_type", config.AdditionalParameters.Keys); + Assert.Equal("code", config.AdditionalParameters["response_type"]); + } + + [Fact] + public void ForOcis_WithTrailingSlash_TrimsCorrectly() { + // Arrange + var serverUrl = "https://ocis.example.com/"; + var clientId = "ocis_client"; + var redirectUri = "https://app.example.com/callback"; + + // Act + var config = OAuth2Config.ForOcis(serverUrl, clientId, redirectUri); + + // Assert + Assert.Equal("https://ocis.example.com/oauth2/auth", config.AuthorizeUrl); + Assert.Equal("https://ocis.example.com/oauth2/token", config.TokenUrl); + } + + [Fact] + public void ForOcis_WithoutTrailingSlash_WorksCorrectly() { + // Arrange + var serverUrl = "https://ocis.example.com"; + var clientId = "ocis_client"; + var redirectUri = "https://app.example.com/callback"; + + // Act + var config = OAuth2Config.ForOcis(serverUrl, clientId, redirectUri); + + // Assert + Assert.Equal("https://ocis.example.com/oauth2/auth", config.AuthorizeUrl); + Assert.Equal("https://ocis.example.com/oauth2/token", config.TokenUrl); + } + + [Fact] + public void ForNextcloud_AndForOcis_HaveDifferentScopes() { + // Arrange & Act + var nextcloudConfig = OAuth2Config.ForNextcloud( + "https://nextcloud.example.com", + "client1", + "http://localhost/callback" + ); + + var ocisConfig = OAuth2Config.ForOcis( + "https://ocis.example.com", + "client2", + "http://localhost/callback" + ); + + // Assert + Assert.NotEqual(nextcloudConfig.Scopes, ocisConfig.Scopes); + Assert.Single(nextcloudConfig.Scopes); + Assert.Equal(3, ocisConfig.Scopes.Length); + } + + [Fact] + public void Record_WithWith_CreatesNewInstance() { + // Arrange + var original = new OAuth2Config { + ClientId = "old_client", + AuthorizeUrl = "https://old.com/authorize", + TokenUrl = "https://old.com/token", + RedirectUri = "https://app.com/callback" + }; + + // Act + var modified = original with { ClientId = "new_client" }; + + // Assert + Assert.Equal("old_client", original.ClientId); + Assert.Equal("new_client", modified.ClientId); + Assert.Equal(original.AuthorizeUrl, modified.AuthorizeUrl); + Assert.Equal(original.TokenUrl, modified.TokenUrl); + } + + [Fact] + public void Record_Equality_PropertiesMatch() { + // Arrange + var config1 = new OAuth2Config { + ClientId = "test_client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "https://app.example.com/callback" + }; + + var config2 = new OAuth2Config { + ClientId = "test_client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "https://app.example.com/callback" + }; + + // Act & Assert - Compare properties since collections are reference types + Assert.Equal(config1.ClientId, config2.ClientId); + Assert.Equal(config1.AuthorizeUrl, config2.AuthorizeUrl); + Assert.Equal(config1.TokenUrl, config2.TokenUrl); + Assert.Equal(config1.RedirectUri, config2.RedirectUri); + Assert.Equal(config1.ClientSecret, config2.ClientSecret); + Assert.Equal(config1.Scopes.Length, config2.Scopes.Length); + Assert.Equal(config1.AdditionalParameters.Count, config2.AdditionalParameters.Count); + } + + [Fact] + public void Scopes_MultipleScopes_StoresCorrectly() { + // Arrange + var scopes = new[] { "read", "write", "delete", "admin" }; + + // Act + var config = new OAuth2Config { + ClientId = "test_client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "https://app.example.com/callback", + Scopes = scopes + }; + + // Assert + Assert.Equal(4, config.Scopes.Length); + Assert.Contains("read", config.Scopes); + Assert.Contains("write", config.Scopes); + Assert.Contains("delete", config.Scopes); + Assert.Contains("admin", config.Scopes); + } + + [Fact] + public void AdditionalParameters_MultipleParameters_StoresCorrectly() { + // Arrange + var additionalParams = new Dictionary { + { "response_type", "code" }, + { "access_type", "offline" }, + { "prompt", "consent" } + }; + + // Act + var config = new OAuth2Config { + ClientId = "test_client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = "https://app.example.com/callback", + AdditionalParameters = additionalParams + }; + + // Assert + Assert.Equal(3, config.AdditionalParameters.Count); + Assert.Equal("code", config.AdditionalParameters["response_type"]); + Assert.Equal("offline", config.AdditionalParameters["access_type"]); + Assert.Equal("consent", config.AdditionalParameters["prompt"]); + } + + [Theory] + [InlineData("http://localhost:8080/callback")] + [InlineData("https://app.example.com/oauth/callback")] + [InlineData("myapp://oauth-callback")] + public void RedirectUri_VariousFormats_AcceptsCorrectly(string redirectUri) { + // Arrange & Act + var config = new OAuth2Config { + ClientId = "test_client", + AuthorizeUrl = "https://example.com/authorize", + TokenUrl = "https://example.com/token", + RedirectUri = redirectUri + }; + + // Assert + Assert.Equal(redirectUri, config.RedirectUri); + } + + [Fact] + public void ForNextcloud_WithComplexServerUrl_HandlesCorrectly() { + // Arrange + var serverUrl = "https://cloud.example.com:8443/nextcloud"; + + // Act + var config = OAuth2Config.ForNextcloud(serverUrl, "client", "http://localhost/callback"); + + // Assert + Assert.Equal("https://cloud.example.com:8443/nextcloud/apps/oauth2/authorize", config.AuthorizeUrl); + Assert.Equal("https://cloud.example.com:8443/nextcloud/apps/oauth2/api/v1/token", config.TokenUrl); + } + + [Fact] + public void ForOcis_WithComplexServerUrl_HandlesCorrectly() { + // Arrange + var serverUrl = "https://ocis.example.com:9200"; + + // Act + var config = OAuth2Config.ForOcis(serverUrl, "client", "http://localhost/callback"); + + // Assert + Assert.Equal("https://ocis.example.com:9200/oauth2/auth", config.AuthorizeUrl); + Assert.Equal("https://ocis.example.com:9200/oauth2/token", config.TokenUrl); + } +} diff --git a/tests/SharpSync.Tests/Auth/OAuth2ResultTests.cs b/tests/SharpSync.Tests/Auth/OAuth2ResultTests.cs new file mode 100644 index 0000000..0e2e9ad --- /dev/null +++ b/tests/SharpSync.Tests/Auth/OAuth2ResultTests.cs @@ -0,0 +1,375 @@ +using Oire.SharpSync.Auth; + +namespace Oire.SharpSync.Tests.Auth; + +public class OAuth2ResultTests { + [Fact] + public void Constructor_RequiredProperties_InitializesCorrectly() { + // Arrange + var accessToken = "test_access_token"; + var expiresAt = DateTime.UtcNow.AddHours(1); + + // Act + var result = new OAuth2Result { + AccessToken = accessToken, + ExpiresAt = expiresAt + }; + + // Assert + Assert.Equal(accessToken, result.AccessToken); + Assert.Equal(expiresAt, result.ExpiresAt); + } + + [Fact] + public void Constructor_AllProperties_InitializesCorrectly() { + // Arrange + var accessToken = "test_access_token"; + var refreshToken = "test_refresh_token"; + var expiresAt = DateTime.UtcNow.AddHours(1); + var tokenType = "Bearer"; + var scopes = new[] { "read", "write" }; + var userId = "user123"; + + // Act + var result = new OAuth2Result { + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresAt = expiresAt, + TokenType = tokenType, + Scopes = scopes, + UserId = userId + }; + + // Assert + Assert.Equal(accessToken, result.AccessToken); + Assert.Equal(refreshToken, result.RefreshToken); + Assert.Equal(expiresAt, result.ExpiresAt); + Assert.Equal(tokenType, result.TokenType); + Assert.Equal(scopes, result.Scopes); + Assert.Equal(userId, result.UserId); + } + + [Fact] + public void TokenType_DefaultValue_IsBearer() { + // Arrange & Act + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(1) + }; + + // Assert + Assert.Equal("Bearer", result.TokenType); + } + + [Fact] + public void IsValid_WithValidToken_ReturnsTrue() { + // Arrange + var result = new OAuth2Result { + AccessToken = "valid_token", + ExpiresAt = DateTime.UtcNow.AddHours(1) + }; + + // Act & Assert + Assert.True(result.IsValid); + } + + [Fact] + public void IsValid_WithExpiredToken_ReturnsFalse() { + // Arrange + var result = new OAuth2Result { + AccessToken = "expired_token", + ExpiresAt = DateTime.UtcNow.AddHours(-1) // Expired 1 hour ago + }; + + // Act & Assert + Assert.False(result.IsValid); + } + + [Fact] + public void IsValid_WithEmptyAccessToken_ReturnsFalse() { + // Arrange + var result = new OAuth2Result { + AccessToken = "", + ExpiresAt = DateTime.UtcNow.AddHours(1) + }; + + // Act & Assert + Assert.False(result.IsValid); + } + + [Fact] + public void IsValid_WithNullAccessToken_ReturnsFalse() { + // Arrange + var result = new OAuth2Result { + AccessToken = null!, + ExpiresAt = DateTime.UtcNow.AddHours(1) + }; + + // Act & Assert + Assert.False(result.IsValid); + } + + [Fact] + public void IsValid_ExpiringNow_ReturnsFalse() { + // Arrange - token expires in 1 second + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddSeconds(1) + }; + + // Act + Thread.Sleep(1100); // Wait for expiration + + // Assert + Assert.False(result.IsValid); + } + + [Fact] + public void WillExpireWithin_BeforeExpiration_ReturnsTrue() { + // Arrange + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddMinutes(30) + }; + + // Act & Assert + Assert.True(result.WillExpireWithin(TimeSpan.FromHours(1))); + } + + [Fact] + public void WillExpireWithin_AfterExpiration_ReturnsFalse() { + // Arrange + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(2) + }; + + // Act & Assert + Assert.False(result.WillExpireWithin(TimeSpan.FromHours(1))); + } + + [Fact] + public void WillExpireWithin_ExactlyAtExpiration_ReturnsTrue() { + // Arrange + var expiresAt = DateTime.UtcNow.AddHours(1); + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = expiresAt + }; + + // Act & Assert + Assert.True(result.WillExpireWithin(TimeSpan.FromHours(1))); + } + + [Fact] + public void WillExpireWithin_AlreadyExpired_ReturnsTrue() { + // Arrange + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(-1) // Already expired + }; + + // Act & Assert + Assert.True(result.WillExpireWithin(TimeSpan.FromMinutes(30))); + } + + [Theory] + [InlineData(5)] + [InlineData(10)] + [InlineData(15)] + [InlineData(30)] + [InlineData(60)] + public void WillExpireWithin_VariousTimeSpans_WorksCorrectly(int minutes) { + // Arrange + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddMinutes(minutes - 1) // Will expire before the threshold + }; + + // Act & Assert + Assert.True(result.WillExpireWithin(TimeSpan.FromMinutes(minutes))); + } + + [Fact] + public void RefreshToken_Null_IsAllowed() { + // Arrange & Act + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + RefreshToken = null + }; + + // Assert + Assert.Null(result.RefreshToken); + } + + [Fact] + public void Scopes_Null_IsAllowed() { + // Arrange & Act + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + Scopes = null + }; + + // Assert + Assert.Null(result.Scopes); + } + + [Fact] + public void Scopes_EmptyArray_IsAllowed() { + // Arrange & Act + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + Scopes = Array.Empty() + }; + + // Assert + Assert.Empty(result.Scopes); + } + + [Fact] + public void Scopes_MultipleScopes_StoresCorrectly() { + // Arrange + var scopes = new[] { "read", "write", "delete", "admin" }; + + // Act + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + Scopes = scopes + }; + + // Assert + Assert.NotNull(result.Scopes); + Assert.Equal(4, result.Scopes.Length); + Assert.Contains("read", result.Scopes); + Assert.Contains("write", result.Scopes); + Assert.Contains("delete", result.Scopes); + Assert.Contains("admin", result.Scopes); + } + + [Fact] + public void UserId_Null_IsAllowed() { + // Arrange & Act + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + UserId = null + }; + + // Assert + Assert.Null(result.UserId); + } + + [Fact] + public void Record_WithWith_CreatesNewInstance() { + // Arrange + var original = new OAuth2Result { + AccessToken = "old_token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + RefreshToken = "refresh" + }; + + // Act + var modified = original with { AccessToken = "new_token" }; + + // Assert + Assert.Equal("old_token", original.AccessToken); + Assert.Equal("new_token", modified.AccessToken); + Assert.Equal(original.RefreshToken, modified.RefreshToken); + Assert.Equal(original.ExpiresAt, modified.ExpiresAt); + } + + [Fact] + public void Record_Equality_WorksCorrectly() { + // Arrange + var expiresAt = DateTime.UtcNow.AddHours(1); + var result1 = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = expiresAt, + RefreshToken = "refresh", + TokenType = "Bearer" + }; + + var result2 = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = expiresAt, + RefreshToken = "refresh", + TokenType = "Bearer" + }; + + // Act & Assert + Assert.Equal(result1, result2); + } + + [Theory] + [InlineData("Bearer")] + [InlineData("Token")] + [InlineData("Basic")] + public void TokenType_CustomValues_SetsCorrectly(string tokenType) { + // Arrange & Act + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(1), + TokenType = tokenType + }; + + // Assert + Assert.Equal(tokenType, result.TokenType); + } + + [Fact] + public void IsValid_JustBeforeExpiry_ReturnsTrue() { + // Arrange + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddSeconds(5) + }; + + // Act & Assert + Assert.True(result.IsValid); + } + + [Fact] + public void ExpiresAt_UtcTime_StoresCorrectly() { + // Arrange + var expiresAt = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc); + + // Act + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = expiresAt + }; + + // Assert + Assert.Equal(expiresAt, result.ExpiresAt); + Assert.Equal(DateTimeKind.Utc, result.ExpiresAt.Kind); + } + + [Fact] + public void WillExpireWithin_ZeroTimeSpan_ReturnsTrueIfExpired() { + // Arrange + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddSeconds(-1) + }; + + // Act & Assert + Assert.True(result.WillExpireWithin(TimeSpan.Zero)); + } + + [Fact] + public void WillExpireWithin_NegativeTimeSpan_HandlesProperly() { + // Arrange + var result = new OAuth2Result { + AccessToken = "test_token", + ExpiresAt = DateTime.UtcNow.AddHours(1) + }; + + // Act & Assert + // A negative timespan means "already expired" from current perspective + Assert.False(result.WillExpireWithin(TimeSpan.FromHours(-1))); + } +} diff --git a/tests/SharpSync.Tests/Core/ConflictAnalysisTests.cs b/tests/SharpSync.Tests/Core/ConflictAnalysisTests.cs new file mode 100644 index 0000000..e1f5b2a --- /dev/null +++ b/tests/SharpSync.Tests/Core/ConflictAnalysisTests.cs @@ -0,0 +1,379 @@ +namespace Oire.SharpSync.Tests.Core; + +public class ConflictAnalysisTests { + [Fact] + public void Constructor_RequiredProperties_InitializesCorrectly() { + // Arrange + var filePath = "test.txt"; + var conflictType = ConflictType.BothModified; + + // Act + var analysis = new ConflictAnalysis { + FilePath = filePath, + ConflictType = conflictType + }; + + // Assert + Assert.Equal(filePath, analysis.FilePath); + Assert.Equal(conflictType, analysis.ConflictType); + } + + [Fact] + public void Constructor_AllProperties_InitializesCorrectly() { + // Arrange + var filePath = "documents/test.txt"; + var conflictType = ConflictType.BothModified; + var localItem = new SyncItem { Path = filePath, Size = 1024, LastModified = DateTime.UtcNow }; + var remoteItem = new SyncItem { Path = filePath, Size = 2048, LastModified = DateTime.UtcNow.AddMinutes(5) }; + var reasoning = "Remote file is newer"; + var localModified = DateTime.UtcNow.AddHours(-1); + var remoteModified = DateTime.UtcNow; + + // Act + var analysis = new ConflictAnalysis { + FilePath = filePath, + ConflictType = conflictType, + LocalItem = localItem, + RemoteItem = remoteItem, + RecommendedResolution = ConflictResolution.UseRemote, + Reasoning = reasoning, + LocalSize = 1024, + RemoteSize = 2048, + SizeDifference = 1024, + LocalModified = localModified, + RemoteModified = remoteModified, + TimeDifference = 3600, + NewerVersion = "Remote", + IsLikelyBinary = false, + IsLikelyTextFile = true + }; + + // Assert + Assert.Equal(filePath, analysis.FilePath); + Assert.Equal(conflictType, analysis.ConflictType); + Assert.Equal(localItem, analysis.LocalItem); + Assert.Equal(remoteItem, analysis.RemoteItem); + Assert.Equal(ConflictResolution.UseRemote, analysis.RecommendedResolution); + Assert.Equal(reasoning, analysis.Reasoning); + Assert.Equal(1024, analysis.LocalSize); + Assert.Equal(2048, analysis.RemoteSize); + Assert.Equal(1024, analysis.SizeDifference); + Assert.Equal(localModified, analysis.LocalModified); + Assert.Equal(remoteModified, analysis.RemoteModified); + Assert.Equal(3600, analysis.TimeDifference); + Assert.Equal("Remote", analysis.NewerVersion); + Assert.False(analysis.IsLikelyBinary); + Assert.True(analysis.IsLikelyTextFile); + } + + [Fact] + public void FileExtension_WithExtension_ReturnsCorrectValue() { + // Arrange + var analysis = new ConflictAnalysis { + FilePath = "documents/test.txt", + ConflictType = ConflictType.BothModified + }; + + // Act + var extension = analysis.FileExtension; + + // Assert + Assert.Equal(".txt", extension); + } + + [Fact] + public void FileExtension_WithoutExtension_ReturnsEmpty() { + // Arrange + var analysis = new ConflictAnalysis { + FilePath = "documents/testfile", + ConflictType = ConflictType.BothModified + }; + + // Act + var extension = analysis.FileExtension; + + // Assert + Assert.Equal("", extension); + } + + [Fact] + public void FileName_WithPath_ReturnsFileNameOnly() { + // Arrange + var analysis = new ConflictAnalysis { + FilePath = "documents/subfolder/test.txt", + ConflictType = ConflictType.BothModified + }; + + // Act + var fileName = analysis.FileName; + + // Assert + Assert.Equal("test.txt", fileName); + } + + [Fact] + public void FileName_WithoutPath_ReturnsFileName() { + // Arrange + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified + }; + + // Act + var fileName = analysis.FileName; + + // Assert + Assert.Equal("test.txt", fileName); + } + + [Theory] + [InlineData(0, "0 B")] + [InlineData(512, "512.0 B")] + [InlineData(1024, "1.0 KB")] + [InlineData(1536, "1.5 KB")] + [InlineData(1048576, "1.0 MB")] + [InlineData(1572864, "1.5 MB")] + [InlineData(1073741824, "1.0 GB")] + [InlineData(1610612736, "1.5 GB")] + [InlineData(1099511627776, "1.0 TB")] + public void FormattedSizeDifference_VariousSizes_FormatsCorrectly(long bytes, string expected) { + // Arrange + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + SizeDifference = bytes + }; + + // Act + var formatted = analysis.FormattedSizeDifference; + + // Assert + Assert.Equal(expected, formatted); + } + + [Fact] + public void FormattedSizeDifference_VeryLargeSize_FormatsAsTerabytes() { + // Arrange + var analysis = new ConflictAnalysis { + FilePath = "huge.bin", + ConflictType = ConflictType.BothModified, + SizeDifference = 1024L * 1024L * 1024L * 1024L * 5L // 5 TB + }; + + // Act + var formatted = analysis.FormattedSizeDifference; + + // Assert + Assert.Equal("5.0 TB", formatted); + } + + [Fact] + public void Reasoning_DefaultValue_IsEmpty() { + // Arrange & Act + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified + }; + + // Assert + Assert.Equal(string.Empty, analysis.Reasoning); + } + + [Theory] + [InlineData(ConflictType.BothModified)] + [InlineData(ConflictType.DeletedLocallyModifiedRemotely)] + [InlineData(ConflictType.ModifiedLocallyDeletedRemotely)] + [InlineData(ConflictType.TypeConflict)] + [InlineData(ConflictType.BothCreated)] + public void ConflictType_AllTypes_SetsCorrectly(ConflictType type) { + // Arrange & Act + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = type + }; + + // Assert + Assert.Equal(type, analysis.ConflictType); + } + + [Theory] + [InlineData(ConflictResolution.Ask)] + [InlineData(ConflictResolution.UseLocal)] + [InlineData(ConflictResolution.UseRemote)] + [InlineData(ConflictResolution.Skip)] + [InlineData(ConflictResolution.RenameLocal)] + [InlineData(ConflictResolution.RenameRemote)] + public void RecommendedResolution_AllResolutions_SetsCorrectly(ConflictResolution resolution) { + // Arrange & Act + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + RecommendedResolution = resolution + }; + + // Assert + Assert.Equal(resolution, analysis.RecommendedResolution); + } + + [Fact] + public void LocalItem_Null_IsAllowed() { + // Arrange & Act + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.DeletedLocallyModifiedRemotely, + LocalItem = null, + RemoteItem = new SyncItem { Path = "test.txt" } + }; + + // Assert + Assert.Null(analysis.LocalItem); + } + + [Fact] + public void RemoteItem_Null_IsAllowed() { + // Arrange & Act + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.ModifiedLocallyDeletedRemotely, + LocalItem = new SyncItem { Path = "test.txt" }, + RemoteItem = null + }; + + // Assert + Assert.Null(analysis.RemoteItem); + } + + [Fact] + public void Record_WithWith_CreatesNewInstance() { + // Arrange + var original = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + RecommendedResolution = ConflictResolution.UseLocal + }; + + // Act + var modified = original with { RecommendedResolution = ConflictResolution.UseRemote }; + + // Assert + Assert.Equal(ConflictResolution.UseLocal, original.RecommendedResolution); + Assert.Equal(ConflictResolution.UseRemote, modified.RecommendedResolution); + Assert.Equal(original.FilePath, modified.FilePath); + Assert.Equal(original.ConflictType, modified.ConflictType); + } + + [Fact] + public void Record_Equality_WorksCorrectly() { + // Arrange + var analysis1 = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + RecommendedResolution = ConflictResolution.UseLocal, + Reasoning = "Test" + }; + + var analysis2 = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + RecommendedResolution = ConflictResolution.UseLocal, + Reasoning = "Test" + }; + + // Act & Assert + Assert.Equal(analysis1, analysis2); + } + + [Fact] + public void NewerVersion_Null_IsAllowed() { + // Arrange & Act + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + NewerVersion = null + }; + + // Assert + Assert.Null(analysis.NewerVersion); + } + + [Fact] + public void NewerVersion_LocalOrRemote_SetsCorrectly() { + // Arrange & Act + var localNewer = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + NewerVersion = "Local" + }; + + var remoteNewer = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + NewerVersion = "Remote" + }; + + // Assert + Assert.Equal("Local", localNewer.NewerVersion); + Assert.Equal("Remote", remoteNewer.NewerVersion); + } + + [Fact] + public void IsLikelyBinary_AndIsLikelyTextFile_CanBothBeFalse() { + // Arrange & Act + var analysis = new ConflictAnalysis { + FilePath = "test.unknown", + ConflictType = ConflictType.BothModified, + IsLikelyBinary = false, + IsLikelyTextFile = false + }; + + // Assert + Assert.False(analysis.IsLikelyBinary); + Assert.False(analysis.IsLikelyTextFile); + } + + [Fact] + public void SizeDifference_ZeroWhenSizesEqual_FormatsAsZero() { + // Arrange + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + LocalSize = 1024, + RemoteSize = 1024, + SizeDifference = 0 + }; + + // Act + var formatted = analysis.FormattedSizeDifference; + + // Assert + Assert.Equal("0 B", formatted); + } + + [Fact] + public void TimeDifference_Zero_IsAllowed() { + // Arrange & Act + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothModified, + TimeDifference = 0 + }; + + // Assert + Assert.Equal(0, analysis.TimeDifference); + } + + [Fact] + public void LocalModified_AndRemoteModified_Null_IsAllowed() { + // Arrange & Act + var analysis = new ConflictAnalysis { + FilePath = "test.txt", + ConflictType = ConflictType.BothCreated, + LocalModified = null, + RemoteModified = null + }; + + // Assert + Assert.Null(analysis.LocalModified); + Assert.Null(analysis.RemoteModified); + } +} diff --git a/tests/SharpSync.Tests/Core/SmartConflictResolverTests.cs b/tests/SharpSync.Tests/Core/SmartConflictResolverTests.cs new file mode 100644 index 0000000..0e1f78b --- /dev/null +++ b/tests/SharpSync.Tests/Core/SmartConflictResolverTests.cs @@ -0,0 +1,520 @@ +namespace Oire.SharpSync.Tests.Core; + +public class SmartConflictResolverTests { + [Fact] + public void Constructor_WithoutParameters_CreatesResolverWithAskDefault() { + // Arrange & Act + var resolver = new SmartConflictResolver(); + + // Assert - resolver should be created successfully + Assert.NotNull(resolver); + } + + [Theory] + [InlineData(ConflictResolution.Ask)] + [InlineData(ConflictResolution.UseLocal)] + [InlineData(ConflictResolution.UseRemote)] + [InlineData(ConflictResolution.Skip)] + [InlineData(ConflictResolution.RenameLocal)] + public void Constructor_WithDefaultResolution_SetsCorrectly(ConflictResolution defaultResolution) { + // Arrange & Act + var resolver = new SmartConflictResolver(null, defaultResolution); + + // Assert - resolver should be created successfully with the specified default + Assert.NotNull(resolver); + } + + [Fact] + public async Task ResolveConflictAsync_WithConflictHandler_InvokesHandler() { + // Arrange + var handlerInvoked = false; + var expectedResolution = ConflictResolution.UseLocal; + + SmartConflictResolver.ConflictHandlerDelegate handler = (analysis, ct) => { + handlerInvoked = true; + return Task.FromResult(expectedResolution); + }; + + var resolver = new SmartConflictResolver(handler); + var localItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = DateTime.UtcNow + }; + var remoteItem = new SyncItem { + Path = "test.txt", + Size = 2048, + LastModified = DateTime.UtcNow.AddMinutes(5) + }; + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act + var result = await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.True(handlerInvoked); + Assert.Equal(expectedResolution, result); + } + + [Fact] + public async Task ResolveConflictAsync_DeletedLocallyModifiedRemotely_RecommiendsUseRemote() { + // Arrange + var resolver = new SmartConflictResolver(); + var remoteItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = DateTime.UtcNow + }; + + var conflict = new FileConflictEventArgs("test.txt", null, remoteItem, ConflictType.DeletedLocallyModifiedRemotely); + + // Act + var result = await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.Equal(ConflictResolution.UseRemote, result); + } + + [Fact] + public async Task ResolveConflictAsync_ModifiedLocallyDeletedRemotely_RecommendsUseLocal() { + // Arrange + var resolver = new SmartConflictResolver(); + var localItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = DateTime.UtcNow + }; + + var conflict = new FileConflictEventArgs("test.txt", localItem, null, ConflictType.ModifiedLocallyDeletedRemotely); + + // Act + var result = await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.Equal(ConflictResolution.UseLocal, result); + } + + [Fact] + public async Task ResolveConflictAsync_TypeConflict_RecommendsAsk() { + // Arrange + var resolver = new SmartConflictResolver(); + var localItem = new SyncItem { + Path = "test", + IsDirectory = false, + Size = 100 + }; + var remoteItem = new SyncItem { + Path = "test", + IsDirectory = true, + Size = 0 + }; + + var conflict = new FileConflictEventArgs("test", localItem, remoteItem, ConflictType.TypeConflict); + + // Act + var result = await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.Equal(ConflictResolution.Ask, result); + } + + [Fact] + public async Task ResolveConflictAsync_BothModified_RemoteNewer_RecommendsUseRemote() { + // Arrange + var resolver = new SmartConflictResolver(); + var localTime = DateTime.UtcNow; + var remoteTime = localTime.AddMinutes(10); + + var localItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = localTime + }; + var remoteItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = remoteTime + }; + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act + var result = await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.Equal(ConflictResolution.UseRemote, result); + } + + [Fact] + public async Task ResolveConflictAsync_BothModified_LocalNewer_RecommendsUseLocal() { + // Arrange + var resolver = new SmartConflictResolver(); + var remoteTime = DateTime.UtcNow; + var localTime = remoteTime.AddMinutes(10); + + var localItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = localTime + }; + var remoteItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = remoteTime + }; + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act + var result = await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.Equal(ConflictResolution.UseLocal, result); + } + + [Fact] + public async Task ResolveConflictAsync_BothModified_SameTime_UsesFallbackResolution() { + // Arrange + var defaultResolution = ConflictResolution.Skip; + var resolver = new SmartConflictResolver(null, defaultResolution); + var time = DateTime.UtcNow; + + var localItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = time + }; + var remoteItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = time + }; + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act + var result = await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.Equal(defaultResolution, result); + } + + [Theory] + [InlineData("test.exe")] + [InlineData("test.dll")] + [InlineData("test.bin")] + [InlineData("test.zip")] + [InlineData("test.jpg")] + [InlineData("test.png")] + [InlineData("test.mp4")] + [InlineData("test.pdf")] + [InlineData("test.docx")] + public async Task ResolveConflictAsync_BinaryFiles_AnalyzesCorrectly(string fileName) { + // Arrange + ConflictAnalysis? capturedAnalysis = null; + SmartConflictResolver.ConflictHandlerDelegate handler = (analysis, ct) => { + capturedAnalysis = analysis; + return Task.FromResult(ConflictResolution.UseLocal); + }; + + var resolver = new SmartConflictResolver(handler); + var localItem = new SyncItem { + Path = fileName, + Size = 1024, + LastModified = DateTime.UtcNow + }; + var remoteItem = new SyncItem { + Path = fileName, + Size = 2048, + LastModified = DateTime.UtcNow.AddMinutes(5) + }; + + var conflict = new FileConflictEventArgs(fileName, localItem, remoteItem, ConflictType.BothModified); + + // Act + await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.NotNull(capturedAnalysis); + Assert.True(capturedAnalysis.IsLikelyBinary); + Assert.False(capturedAnalysis.IsLikelyTextFile); + } + + [Theory] + [InlineData("test.txt")] + [InlineData("test.md")] + [InlineData("test.json")] + [InlineData("test.cs")] + [InlineData("test.js")] + [InlineData("test.py")] + [InlineData("test.html")] + [InlineData("test.xml")] + [InlineData("test.yml")] + public async Task ResolveConflictAsync_TextFiles_AnalyzesCorrectly(string fileName) { + // Arrange + ConflictAnalysis? capturedAnalysis = null; + SmartConflictResolver.ConflictHandlerDelegate handler = (analysis, ct) => { + capturedAnalysis = analysis; + return Task.FromResult(ConflictResolution.UseLocal); + }; + + var resolver = new SmartConflictResolver(handler); + var localItem = new SyncItem { + Path = fileName, + Size = 1024, + LastModified = DateTime.UtcNow + }; + var remoteItem = new SyncItem { + Path = fileName, + Size = 2048, + LastModified = DateTime.UtcNow.AddMinutes(5) + }; + + var conflict = new FileConflictEventArgs(fileName, localItem, remoteItem, ConflictType.BothModified); + + // Act + await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.NotNull(capturedAnalysis); + Assert.True(capturedAnalysis.IsLikelyTextFile); + Assert.False(capturedAnalysis.IsLikelyBinary); + } + + [Fact] + public async Task ResolveConflictAsync_UnknownExtension_NotMarkedAsBinaryOrText() { + // Arrange + ConflictAnalysis? capturedAnalysis = null; + SmartConflictResolver.ConflictHandlerDelegate handler = (analysis, ct) => { + capturedAnalysis = analysis; + return Task.FromResult(ConflictResolution.UseLocal); + }; + + var resolver = new SmartConflictResolver(handler); + var localItem = new SyncItem { + Path = "test.unknownext", + Size = 1024, + LastModified = DateTime.UtcNow + }; + var remoteItem = new SyncItem { + Path = "test.unknownext", + Size = 2048, + LastModified = DateTime.UtcNow.AddMinutes(5) + }; + + var conflict = new FileConflictEventArgs("test.unknownext", localItem, remoteItem, ConflictType.BothModified); + + // Act + await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.NotNull(capturedAnalysis); + Assert.False(capturedAnalysis.IsLikelyBinary); + Assert.False(capturedAnalysis.IsLikelyTextFile); + } + + [Fact] + public async Task ResolveConflictAsync_SizeDifference_CalculatesCorrectly() { + // Arrange + ConflictAnalysis? capturedAnalysis = null; + SmartConflictResolver.ConflictHandlerDelegate handler = (analysis, ct) => { + capturedAnalysis = analysis; + return Task.FromResult(ConflictResolution.UseLocal); + }; + + var resolver = new SmartConflictResolver(handler); + var localItem = new SyncItem { + Path = "test.txt", + Size = 1000, + LastModified = DateTime.UtcNow + }; + var remoteItem = new SyncItem { + Path = "test.txt", + Size = 3500, + LastModified = DateTime.UtcNow.AddMinutes(5) + }; + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act + await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.NotNull(capturedAnalysis); + Assert.Equal(1000, capturedAnalysis.LocalSize); + Assert.Equal(3500, capturedAnalysis.RemoteSize); + Assert.Equal(2500, capturedAnalysis.SizeDifference); + } + + [Fact] + public async Task ResolveConflictAsync_TimeDifference_CalculatesCorrectly() { + // Arrange + ConflictAnalysis? capturedAnalysis = null; + SmartConflictResolver.ConflictHandlerDelegate handler = (analysis, ct) => { + capturedAnalysis = analysis; + return Task.FromResult(ConflictResolution.UseLocal); + }; + + var resolver = new SmartConflictResolver(handler); + var localTime = DateTime.UtcNow; + var remoteTime = localTime.AddMinutes(10); // 600 seconds difference + + var localItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = localTime + }; + var remoteItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = remoteTime + }; + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act + await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.NotNull(capturedAnalysis); + Assert.Equal(600, capturedAnalysis.TimeDifference, 1.0); // Allow 1 second tolerance + Assert.Equal("Remote", capturedAnalysis.NewerVersion); + } + + [Fact] + public async Task ResolveConflictAsync_CancellationRequested_ThrowsOperationCanceledException() { + // Arrange + var resolver = new SmartConflictResolver(); + var localItem = new SyncItem { Path = "test.txt", LastModified = DateTime.UtcNow }; + var remoteItem = new SyncItem { Path = "test.txt", LastModified = DateTime.UtcNow }; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act & Assert + await Assert.ThrowsAnyAsync(() => + resolver.ResolveConflictAsync(conflict, cts.Token)); + } + + [Fact] + public async Task ResolveConflictAsync_HandlerCancellation_ThrowsOperationCanceledException() { + // Arrange + SmartConflictResolver.ConflictHandlerDelegate handler = async (analysis, ct) => { + await Task.Delay(100, ct); // This should throw when cancelled + return ConflictResolution.UseLocal; + }; + + var resolver = new SmartConflictResolver(handler); + var localItem = new SyncItem { Path = "test.txt", LastModified = DateTime.UtcNow }; + var remoteItem = new SyncItem { Path = "test.txt", LastModified = DateTime.UtcNow }; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act & Assert + await Assert.ThrowsAnyAsync(() => + resolver.ResolveConflictAsync(conflict, cts.Token)); + } + + [Fact] + public async Task ResolveConflictAsync_BothModifiedWithin60Seconds_IncludesSimultaneousEditReasoning() { + // Arrange + ConflictAnalysis? capturedAnalysis = null; + SmartConflictResolver.ConflictHandlerDelegate handler = (analysis, ct) => { + capturedAnalysis = analysis; + return Task.FromResult(ConflictResolution.UseLocal); + }; + + var resolver = new SmartConflictResolver(handler); + var localTime = DateTime.UtcNow; + var remoteTime = localTime.AddSeconds(30); // Within 60 seconds + + var localItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = localTime + }; + var remoteItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = remoteTime + }; + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act + await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.NotNull(capturedAnalysis); + Assert.Contains("within 1 minute", capturedAnalysis.Reasoning, StringComparison.OrdinalIgnoreCase); + Assert.Contains("simultaneous", capturedAnalysis.Reasoning, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ResolveConflictAsync_NoHandler_FallsBackToDefault() { + // Arrange + var defaultResolution = ConflictResolution.RenameLocal; + var resolver = new SmartConflictResolver(null, defaultResolution); + + // Use same timestamp for both to ensure they're equal + var timestamp = DateTime.UtcNow; + + var localItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = timestamp + }; + var remoteItem = new SyncItem { + Path = "test.txt", + Size = 1024, + LastModified = timestamp + }; + + var conflict = new FileConflictEventArgs("test.txt", localItem, remoteItem, ConflictType.BothModified); + + // Act + var result = await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.Equal(defaultResolution, result); + } + + [Fact] + public async Task ResolveConflictAsync_LargeFiles_HandlesCorrectly() { + // Arrange + ConflictAnalysis? capturedAnalysis = null; + SmartConflictResolver.ConflictHandlerDelegate handler = (analysis, ct) => { + capturedAnalysis = analysis; + return Task.FromResult(ConflictResolution.UseRemote); + }; + + var resolver = new SmartConflictResolver(handler); + var localItem = new SyncItem { + Path = "largefile.bin", + Size = 1024L * 1024L * 1024L * 5L, // 5GB + LastModified = DateTime.UtcNow.AddHours(-1) + }; + var remoteItem = new SyncItem { + Path = "largefile.bin", + Size = 1024L * 1024L * 1024L * 6L, // 6GB + LastModified = DateTime.UtcNow + }; + + var conflict = new FileConflictEventArgs("largefile.bin", localItem, remoteItem, ConflictType.BothModified); + + // Act + await resolver.ResolveConflictAsync(conflict); + + // Assert + Assert.NotNull(capturedAnalysis); + Assert.Equal(1024L * 1024L * 1024L * 5L, capturedAnalysis.LocalSize); + Assert.Equal(1024L * 1024L * 1024L * 6L, capturedAnalysis.RemoteSize); + Assert.True(capturedAnalysis.IsLikelyBinary); + } +} diff --git a/tests/SharpSync.Tests/SharpSync.Tests.csproj b/tests/SharpSync.Tests/SharpSync.Tests.csproj index 34041df..7e4631e 100644 --- a/tests/SharpSync.Tests/SharpSync.Tests.csproj +++ b/tests/SharpSync.Tests/SharpSync.Tests.csproj @@ -10,6 +10,8 @@ false false true + + $(NoWarn);NETSDK1206 diff --git a/tests/SharpSync.Tests/Storage/LocalFileStorageTests.cs b/tests/SharpSync.Tests/Storage/LocalFileStorageTests.cs index 3d61aa9..7990015 100644 --- a/tests/SharpSync.Tests/Storage/LocalFileStorageTests.cs +++ b/tests/SharpSync.Tests/Storage/LocalFileStorageTests.cs @@ -232,4 +232,326 @@ public async Task GetStorageInfoAsync_ReturnsInfo() { Assert.True(info.UsedSpace >= 0); Assert.True(info.AvailableSpace >= 0); } + + [Fact] + public async Task WriteFileAsync_LargeFile_HandlesCorrectly() { + // Arrange + var filePath = "large.bin"; + var largeContent = new byte[5 * 1024 * 1024]; // 5 MB + new Random().NextBytes(largeContent); + + // Act + using var stream = new MemoryStream(largeContent); + await _storage.WriteFileAsync(filePath, stream); + + // Assert + var fullPath = Path.Combine(_testDirectory, filePath); + Assert.True(File.Exists(fullPath)); + var fileInfo = new FileInfo(fullPath); + Assert.Equal(largeContent.Length, fileInfo.Length); + } + + [Fact] + public async Task WriteFileAsync_EmptyFile_CreatesEmptyFile() { + // Arrange + var filePath = "empty.txt"; + using var stream = new MemoryStream(); + + // Act + await _storage.WriteFileAsync(filePath, stream); + + // Assert + var fullPath = Path.Combine(_testDirectory, filePath); + Assert.True(File.Exists(fullPath)); + var fileInfo = new FileInfo(fullPath); + Assert.Equal(0, fileInfo.Length); + } + + [Fact] + public async Task WriteFileAsync_OverwritesExistingFile() { + // Arrange + var filePath = "overwrite.txt"; + var fullPath = Path.Combine(_testDirectory, filePath); + await File.WriteAllTextAsync(fullPath, "original content"); + + // Act + var newContent = "new content"; + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(newContent)); + await _storage.WriteFileAsync(filePath, stream); + + // Assert + var fileContent = await File.ReadAllTextAsync(fullPath); + Assert.Equal(newContent, fileContent); + } + + [Fact] + public async Task WriteFileAsync_CreatesIntermediateDirectories() { + // Arrange + var filePath = "deeply/nested/directory/file.txt"; + var content = "test content"; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(filePath, stream); + + // Assert + var fullPath = Path.Combine(_testDirectory, filePath); + Assert.True(File.Exists(fullPath)); + var fileContent = await File.ReadAllTextAsync(fullPath); + Assert.Equal(content, fileContent); + } + + [Fact] + public async Task ReadFileAsync_LargeFile_ReadsCorrectly() { + // Arrange + var filePath = "large_read.bin"; + var largeContent = new byte[5 * 1024 * 1024]; // 5 MB + new Random().NextBytes(largeContent); + var fullPath = Path.Combine(_testDirectory, filePath); + await File.WriteAllBytesAsync(fullPath, largeContent); + + // Act + using var stream = await _storage.ReadFileAsync(filePath); + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream); + var readContent = memoryStream.ToArray(); + + // Assert + Assert.Equal(largeContent.Length, readContent.Length); + Assert.Equal(largeContent, readContent); + } + + [Fact] + public async Task ListItemsAsync_EmptyDirectory_ReturnsEmpty() { + // Arrange + var subdir = "empty_subdir"; + Directory.CreateDirectory(Path.Combine(_testDirectory, subdir)); + + // Act + var items = await _storage.ListItemsAsync(subdir); + + // Assert + Assert.Empty(items); + } + + [Fact] + public async Task ListItemsAsync_WithSubdirectories_ListsImmediateItems() { + // Arrange + var dir1 = Path.Combine(_testDirectory, "dir1"); + var dir2 = Path.Combine(dir1, "dir2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + + await File.WriteAllTextAsync(Path.Combine(_testDirectory, "root.txt"), "root"); + await File.WriteAllTextAsync(Path.Combine(dir1, "file1.txt"), "file1"); + await File.WriteAllTextAsync(Path.Combine(dir2, "file2.txt"), "file2"); + + // Act + var allItems = await _storage.ListItemsAsync(""); + + // Assert + var itemsList = allItems.ToList(); + Assert.True(itemsList.Count >= 2); // At least root.txt and dir1 (not recursive) + } + + [Fact] + public async Task DeleteAsync_Directory_DeletesDirectory() { + // Arrange + var dirPath = "test_delete_dir"; + var fullPath = Path.Combine(_testDirectory, dirPath); + Directory.CreateDirectory(fullPath); + + // Act + await _storage.DeleteAsync(dirPath); + + // Assert + Assert.False(Directory.Exists(fullPath)); + } + + [Fact] + public async Task DeleteAsync_NonexistentItem_DoesNotThrow() { + // Act & Assert - should not throw + await _storage.DeleteAsync("nonexistent.txt"); + } + + [Fact] + public async Task MoveAsync_ToSubdirectory_MovesCorrectly() { + // Arrange + var sourcePath = "source.txt"; + var targetPath = "subdir/target.txt"; + var sourceFullPath = Path.Combine(_testDirectory, sourcePath); + var content = "test content"; + await File.WriteAllTextAsync(sourceFullPath, content); + + // Act + await _storage.MoveAsync(sourcePath, targetPath); + + // Assert + Assert.False(File.Exists(sourceFullPath)); + var targetFullPath = Path.Combine(_testDirectory, targetPath); + Assert.True(File.Exists(targetFullPath)); + var targetContent = await File.ReadAllTextAsync(targetFullPath); + Assert.Equal(content, targetContent); + } + + [Fact] + public async Task GetItemAsync_Directory_ReturnsDirectoryItem() { + // Arrange + var dirPath = "test_dir"; + var fullPath = Path.Combine(_testDirectory, dirPath); + Directory.CreateDirectory(fullPath); + + // Act + var item = await _storage.GetItemAsync(dirPath); + + // Assert + Assert.NotNull(item); + Assert.True(item.IsDirectory); + Assert.Equal(dirPath, item.Path); + } + + [Fact] + public async Task GetItemAsync_NonexistentItem_ReturnsNull() { + // Act + var item = await _storage.GetItemAsync("nonexistent.txt"); + + // Assert + Assert.Null(item); + } + + [Fact] + public async Task ComputeHashAsync_DifferentContent_DifferentHashes() { + // Arrange + var file1 = "file1.txt"; + var file2 = "file2.txt"; + await File.WriteAllTextAsync(Path.Combine(_testDirectory, file1), "content 1"); + await File.WriteAllTextAsync(Path.Combine(_testDirectory, file2), "content 2"); + + // Act + var hash1 = await _storage.ComputeHashAsync(file1); + var hash2 = await _storage.ComputeHashAsync(file2); + + // Assert + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public async Task ComputeHashAsync_SameContent_SameHashes() { + // Arrange + var file1 = "file1.txt"; + var file2 = "file2.txt"; + var content = "same content"; + await File.WriteAllTextAsync(Path.Combine(_testDirectory, file1), content); + await File.WriteAllTextAsync(Path.Combine(_testDirectory, file2), content); + + // Act + var hash1 = await _storage.ComputeHashAsync(file1); + var hash2 = await _storage.ComputeHashAsync(file2); + + // Assert + Assert.Equal(hash1, hash2); + } + + [Fact] + public async Task ComputeHashAsync_NonexistentFile_ThrowsException() { + // Act & Assert + await Assert.ThrowsAsync(() => + _storage.ComputeHashAsync("nonexistent.txt")); + } + + [Fact] + public async Task ExistsAsync_Directory_ReturnsTrue() { + // Arrange + var dirPath = "test_exists_dir"; + Directory.CreateDirectory(Path.Combine(_testDirectory, dirPath)); + + // Act + var result = await _storage.ExistsAsync(dirPath); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("file with spaces.txt")] + [InlineData("file-with-dashes.txt")] + [InlineData("file_with_underscores.txt")] + [InlineData("file.multiple.dots.txt")] + public async Task WriteFileAsync_SpecialFileNames_HandlesCorrectly(string fileName) { + // Arrange + var content = "test content"; + + // Act + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); + await _storage.WriteFileAsync(fileName, stream); + + // Assert + var fullPath = Path.Combine(_testDirectory, fileName); + Assert.True(File.Exists(fullPath)); + } + + [Fact] + public async Task CreateDirectoryAsync_AlreadyExists_DoesNotThrow() { + // Arrange + var dirPath = "existing_dir"; + await _storage.CreateDirectoryAsync(dirPath); + + // Act & Assert - should not throw + await _storage.CreateDirectoryAsync(dirPath); + + // Verify it still exists + var fullPath = Path.Combine(_testDirectory, dirPath); + Assert.True(Directory.Exists(fullPath)); + } + + [Fact] + public async Task GetItemAsync_WithMetadata_IncludesMetadata() { + // Arrange + var filePath = "metadata.txt"; + var content = "test content"; + var fullPath = Path.Combine(_testDirectory, filePath); + await File.WriteAllTextAsync(fullPath, content); + + // Act + var item = await _storage.GetItemAsync(filePath); + + // Assert + Assert.NotNull(item); + Assert.NotEqual(default(DateTime), item.LastModified); + Assert.True(item.Size > 0); + // Hash is not computed by GetItemAsync, use ComputeHashAsync separately + } + + [Theory] + [InlineData(".txt", "text/plain")] + [InlineData(".json", "application/json")] + [InlineData(".xml", "application/xml")] + [InlineData(".pdf", "application/pdf")] + [InlineData(".jpg", "image/jpeg")] + [InlineData(".png", "image/png")] + public async Task GetItemAsync_MimeTypes_DetectsCorrectly(string extension, string expectedMimeType) { + // Arrange + var fileName = $"test{extension}"; + var fullPath = Path.Combine(_testDirectory, fileName); + await File.WriteAllTextAsync(fullPath, "content"); + + // Act + var item = await _storage.GetItemAsync(fileName); + + // Assert + Assert.NotNull(item); + Assert.Equal(expectedMimeType, item.MimeType); + } + + [Fact] + public void RootPath_Property_ReturnsCorrectPath() { + // Assert + Assert.Equal(_testDirectory, _storage.RootPath); + } + + [Fact] + public void StorageType_Property_ReturnsLocal() { + // Assert + Assert.Equal(StorageType.Local, _storage.StorageType); + } } diff --git a/tests/SharpSync.Tests/Storage/ProgressStreamTests.cs b/tests/SharpSync.Tests/Storage/ProgressStreamTests.cs new file mode 100644 index 0000000..763b884 --- /dev/null +++ b/tests/SharpSync.Tests/Storage/ProgressStreamTests.cs @@ -0,0 +1,405 @@ +using System.Reflection; + +namespace Oire.SharpSync.Tests.Storage; + +public class ProgressStreamTests { + [Fact] + public void Constructor_ValidParameters_InitializesCorrectly() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + var progressCallbackInvoked = false; + Action progressCallback = (current, total) => { + progressCallbackInvoked = true; + }; + + // Act + using var progressStream = CreateProgressStream(innerStream, 5, progressCallback); + + // Assert + Assert.NotNull(progressStream); + Assert.True(progressCallbackInvoked); // Constructor should report initial progress + } + + [Fact] + public void Constructor_NullInnerStream_ThrowsArgumentNullException() { + // Arrange + Action progressCallback = (current, total) => { }; + + // Act & Assert - Reflection wraps in TargetInvocationException + var exception = Assert.Throws(() => + CreateProgressStream(null!, 100, progressCallback)); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void Constructor_NullProgressCallback_ThrowsArgumentNullException() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + + // Act & Assert - Reflection wraps in TargetInvocationException + var exception = Assert.Throws(() => + CreateProgressStream(innerStream, 3, null!)); + Assert.IsType(exception.InnerException); + } + + [Fact] + public void Constructor_ReportsInitialProgress() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + long reportedCurrent = -1; + long reportedTotal = -1; + Action progressCallback = (current, total) => { + reportedCurrent = current; + reportedTotal = total; + }; + + // Act + using var progressStream = CreateProgressStream(innerStream, 5, progressCallback); + + // Assert + Assert.Equal(0, reportedCurrent); + Assert.Equal(5, reportedTotal); + } + + [Fact] + public void Read_ReportsProgress() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + var progressReports = new List<(long current, long total)>(); + Action progressCallback = (current, total) => { + progressReports.Add((current, total)); + }; + + using var progressStream = CreateProgressStream(innerStream, 5, progressCallback); + var buffer = new byte[3]; + + // Act + var bytesRead = progressStream.Read(buffer, 0, 3); + + // Assert + Assert.Equal(3, bytesRead); + Assert.True(progressReports.Count >= 2); // Initial + after read + Assert.Equal(3, progressReports[^1].current); // Last report should show 3 bytes read + Assert.Equal(5, progressReports[^1].total); + } + + [Fact] + public async Task ReadAsync_ReportsProgress() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + var progressReports = new List<(long current, long total)>(); + Action progressCallback = (current, total) => { + progressReports.Add((current, total)); + }; + + using var progressStream = CreateProgressStream(innerStream, 5, progressCallback); + var buffer = new byte[3]; + + // Act + var bytesRead = await progressStream.ReadAsync(buffer.AsMemory(0, 3)); + + // Assert + Assert.Equal(3, bytesRead); + Assert.True(progressReports.Count >= 2); // Initial + after read + Assert.Equal(3, progressReports[^1].current); + Assert.Equal(5, progressReports[^1].total); + } + + [Fact] + public async Task ReadAsync_WithMemory_ReportsProgress() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + var progressReports = new List<(long current, long total)>(); + Action progressCallback = (current, total) => { + progressReports.Add((current, total)); + }; + + using var progressStream = CreateProgressStream(innerStream, 5, progressCallback); + var buffer = new byte[3]; + + // Act + var bytesRead = await progressStream.ReadAsync(buffer.AsMemory(0, 3)); + + // Assert + Assert.Equal(3, bytesRead); + Assert.True(progressReports.Count >= 2); + Assert.Equal(3, progressReports[^1].current); + Assert.Equal(5, progressReports[^1].total); + } + + [Fact] + public void Read_MultipleReads_AccumulatesProgress() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + var progressReports = new List<(long current, long total)>(); + Action progressCallback = (current, total) => { + progressReports.Add((current, total)); + }; + + using var progressStream = CreateProgressStream(innerStream, 10, progressCallback); + var buffer = new byte[3]; + + // Act + var read1 = progressStream.Read(buffer, 0, 3); // Read 3 bytes + var read2 = progressStream.Read(buffer, 0, 3); // Read 3 more bytes + var read3 = progressStream.Read(buffer, 0, 3); // Read 3 more bytes + + // Assert + Assert.Equal(3, read1); + Assert.Equal(3, read2); + Assert.Equal(3, read3); + var lastReport = progressReports[^1]; + Assert.Equal(9, lastReport.current); // Total 9 bytes read + Assert.Equal(10, lastReport.total); + } + + [Fact] + public void Read_ZeroBytes_DoesNotReportProgress() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + var progressCount = 0; + Action progressCallback = (current, total) => { + progressCount++; + }; + + using var progressStream = CreateProgressStream(innerStream, 3, progressCallback); + var buffer = new byte[3]; + + // Move to end so next read returns 0 + var firstRead = progressStream.Read(buffer, 0, 3); + Assert.Equal(3, firstRead); + var initialCount = progressCount; + + // Act + var bytesRead = progressStream.Read(buffer, 0, 3); // Should read 0 bytes + + // Assert + Assert.Equal(0, bytesRead); + Assert.Equal(initialCount, progressCount); // No additional progress reported for 0 bytes + } + + [Fact] + public void CanRead_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var progressStream = CreateProgressStream(innerStream, 3, (c, t) => { }); + + // Act & Assert + Assert.Equal(innerStream.CanRead, progressStream.CanRead); + } + + [Fact] + public void CanSeek_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var progressStream = CreateProgressStream(innerStream, 3, (c, t) => { }); + + // Act & Assert + Assert.Equal(innerStream.CanSeek, progressStream.CanSeek); + } + + [Fact] + public void CanWrite_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var progressStream = CreateProgressStream(innerStream, 3, (c, t) => { }); + + // Act & Assert + Assert.Equal(innerStream.CanWrite, progressStream.CanWrite); + } + + [Fact] + public void Length_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + using var progressStream = CreateProgressStream(innerStream, 5, (c, t) => { }); + + // Act & Assert + Assert.Equal(innerStream.Length, progressStream.Length); + } + + [Fact] + public void Position_GetAndSet_ReflectsInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + using var progressStream = CreateProgressStream(innerStream, 5, (c, t) => { }); + + // Act + progressStream.Position = 3; + + // Assert + Assert.Equal(3, progressStream.Position); + Assert.Equal(3, innerStream.Position); + } + + [Fact] + public void Seek_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + using var progressStream = CreateProgressStream(innerStream, 5, (c, t) => { }); + + // Act + var newPosition = progressStream.Seek(2, SeekOrigin.Begin); + + // Assert + Assert.Equal(2, newPosition); + Assert.Equal(2, innerStream.Position); + } + + [Fact] + public void Flush_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var progressStream = CreateProgressStream(innerStream, 3, (c, t) => { }); + + // Act & Assert - should not throw + progressStream.Flush(); + } + + [Fact] + public async Task FlushAsync_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3]); + using var progressStream = CreateProgressStream(innerStream, 3, (c, t) => { }); + + // Act & Assert - should not throw + await progressStream.FlushAsync(); + } + + [Fact] + public void Write_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream(); + using var progressStream = CreateProgressStream(innerStream, 10, (c, t) => { }); + var data = new byte[] { 1, 2, 3 }; + + // Act + progressStream.Write(data, 0, 3); + + // Assert + innerStream.Position = 0; + var written = new byte[3]; + var bytesRead = innerStream.Read(written, 0, 3); + Assert.Equal(3, bytesRead); + Assert.Equal(data, written); + } + + [Fact] + public async Task WriteAsync_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream(); + using var progressStream = CreateProgressStream(innerStream, 10, (c, t) => { }); + var data = new byte[] { 1, 2, 3 }; + + // Act + await progressStream.WriteAsync(data.AsMemory(0, 3)); + + // Assert + innerStream.Position = 0; + var written = new byte[3]; + var bytesRead = await innerStream.ReadAsync(written.AsMemory(0, 3)); + Assert.Equal(3, bytesRead); + Assert.Equal(data, written); + } + + [Fact] + public async Task WriteAsync_WithReadOnlyMemory_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream(); + using var progressStream = CreateProgressStream(innerStream, 10, (c, t) => { }); + var data = new byte[] { 1, 2, 3 }; + + // Act + await progressStream.WriteAsync(new ReadOnlyMemory(data)); + + // Assert + innerStream.Position = 0; + var written = new byte[3]; + var bytesRead = await innerStream.ReadAsync(written.AsMemory(0, 3)); + Assert.Equal(3, bytesRead); + Assert.Equal(data, written); + } + + [Fact] + public void SetLength_DelegatesToInnerStream() { + // Arrange + using var innerStream = new MemoryStream(); + using var progressStream = CreateProgressStream(innerStream, 10, (c, t) => { }); + + // Act + progressStream.SetLength(100); + + // Assert + Assert.Equal(100, innerStream.Length); + Assert.Equal(100, progressStream.Length); + } + + [Fact] + public void Dispose_DisposesInnerStream() { + // Arrange + var innerStream = new MemoryStream([1, 2, 3]); + var progressStream = CreateProgressStream(innerStream, 3, (c, t) => { }); + + // Act + progressStream.Dispose(); + + // Assert + Assert.Throws(() => innerStream.ReadByte()); + } + + [Fact] + public async Task ReadAsync_WithCancellation_RespectsCancellationToken() { + // Arrange + using var innerStream = new MemoryStream([1, 2, 3, 4, 5]); + using var progressStream = CreateProgressStream(innerStream, 5, (c, t) => { }); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var buffer = new byte[5]; + + // Act & Assert +#pragma warning disable CA2022, CA1835 // Testing cancellation behavior + await Assert.ThrowsAnyAsync(async () => + await progressStream.ReadAsync(buffer, 0, buffer.Length, cts.Token)); +#pragma warning restore CA2022, CA1835 + } + + [Fact] + public void Read_LargeFile_TracksProgressCorrectly() { + // Arrange + var largeData = new byte[10000]; + for (int i = 0; i < largeData.Length; i++) { + largeData[i] = (byte)(i % 256); + } + + using var innerStream = new MemoryStream(largeData); + var progressReports = new List<(long current, long total)>(); + Action progressCallback = (current, total) => { + progressReports.Add((current, total)); + }; + + using var progressStream = CreateProgressStream(innerStream, largeData.Length, progressCallback); + var buffer = new byte[1000]; + + // Act + while (progressStream.Read(buffer, 0, buffer.Length) > 0) { + // Read entire stream + } + + // Assert + var lastReport = progressReports[^1]; + Assert.Equal(10000, lastReport.current); + Assert.Equal(10000, lastReport.total); + } + + // Helper method to create ProgressStream using reflection since it's internal + private static Stream CreateProgressStream(Stream innerStream, long totalLength, Action progressCallback) { + var assembly = typeof(LocalFileStorage).Assembly; + var progressStreamType = assembly.GetType("Oire.SharpSync.Storage.ProgressStream"); + if (progressStreamType == null) { + throw new InvalidOperationException("ProgressStream type not found"); + } + + return (Stream)Activator.CreateInstance(progressStreamType, innerStream, totalLength, progressCallback)!; + } +} diff --git a/tests/SharpSync.Tests/Storage/ServerCapabilitiesTests.cs b/tests/SharpSync.Tests/Storage/ServerCapabilitiesTests.cs new file mode 100644 index 0000000..ee5cf46 --- /dev/null +++ b/tests/SharpSync.Tests/Storage/ServerCapabilitiesTests.cs @@ -0,0 +1,322 @@ +namespace Oire.SharpSync.Tests.Storage; + +public class ServerCapabilitiesTests { + [Fact] + public void Constructor_DefaultValues_AllPropertiesFalseOrEmpty() { + // Arrange & Act + var capabilities = new ServerCapabilities(); + + // Assert + Assert.False(capabilities.IsNextcloud); + Assert.False(capabilities.IsOcis); + Assert.False(capabilities.SupportsChunking); + Assert.False(capabilities.SupportsOcisChunking); + Assert.Equal("", capabilities.ServerVersion); + Assert.Equal(0, capabilities.ChunkingVersion); + } + + [Fact] + public void IsNextcloud_SetToTrue_UpdatesCorrectly() { + // Arrange + var capabilities = new ServerCapabilities(); + + // Act + capabilities.IsNextcloud = true; + + // Assert + Assert.True(capabilities.IsNextcloud); + } + + [Fact] + public void IsOcis_SetToTrue_UpdatesCorrectly() { + // Arrange + var capabilities = new ServerCapabilities(); + + // Act + capabilities.IsOcis = true; + + // Assert + Assert.True(capabilities.IsOcis); + } + + [Fact] + public void ServerVersion_SetValue_UpdatesCorrectly() { + // Arrange + var capabilities = new ServerCapabilities(); + var version = "28.0.1"; + + // Act + capabilities.ServerVersion = version; + + // Assert + Assert.Equal(version, capabilities.ServerVersion); + } + + [Fact] + public void SupportsChunking_SetToTrue_UpdatesCorrectly() { + // Arrange + var capabilities = new ServerCapabilities(); + + // Act + capabilities.SupportsChunking = true; + + // Assert + Assert.True(capabilities.SupportsChunking); + } + + [Fact] + public void ChunkingVersion_SetValue_UpdatesCorrectly() { + // Arrange + var capabilities = new ServerCapabilities(); + + // Act + capabilities.ChunkingVersion = 2; + + // Assert + Assert.Equal(2, capabilities.ChunkingVersion); + } + + [Fact] + public void SupportsOcisChunking_SetToTrue_UpdatesCorrectly() { + // Arrange + var capabilities = new ServerCapabilities(); + + // Act + capabilities.SupportsOcisChunking = true; + + // Assert + Assert.True(capabilities.SupportsOcisChunking); + } + + [Fact] + public void IsGenericWebDav_WhenNeitherNextcloudNorOcis_ReturnsTrue() { + // Arrange + var capabilities = new ServerCapabilities { + IsNextcloud = false, + IsOcis = false + }; + + // Act & Assert + Assert.True(capabilities.IsGenericWebDav); + } + + [Fact] + public void IsGenericWebDav_WhenNextcloud_ReturnsFalse() { + // Arrange + var capabilities = new ServerCapabilities { + IsNextcloud = true, + IsOcis = false + }; + + // Act & Assert + Assert.False(capabilities.IsGenericWebDav); + } + + [Fact] + public void IsGenericWebDav_WhenOcis_ReturnsFalse() { + // Arrange + var capabilities = new ServerCapabilities { + IsNextcloud = false, + IsOcis = true + }; + + // Act & Assert + Assert.False(capabilities.IsGenericWebDav); + } + + [Fact] + public void IsGenericWebDav_WhenBothNextcloudAndOcis_ReturnsFalse() { + // Arrange + var capabilities = new ServerCapabilities { + IsNextcloud = true, + IsOcis = true + }; + + // Act & Assert + Assert.False(capabilities.IsGenericWebDav); + } + + [Fact] + public void NextcloudCapabilities_FullConfiguration_SetsCorrectly() { + // Arrange & Act + var capabilities = new ServerCapabilities { + IsNextcloud = true, + ServerVersion = "28.0.1", + SupportsChunking = true, + ChunkingVersion = 2 + }; + + // Assert + Assert.True(capabilities.IsNextcloud); + Assert.Equal("28.0.1", capabilities.ServerVersion); + Assert.True(capabilities.SupportsChunking); + Assert.Equal(2, capabilities.ChunkingVersion); + Assert.False(capabilities.IsGenericWebDav); + } + + [Fact] + public void OcisCapabilities_FullConfiguration_SetsCorrectly() { + // Arrange & Act + var capabilities = new ServerCapabilities { + IsOcis = true, + ServerVersion = "5.0.0", + SupportsOcisChunking = true + }; + + // Assert + Assert.True(capabilities.IsOcis); + Assert.Equal("5.0.0", capabilities.ServerVersion); + Assert.True(capabilities.SupportsOcisChunking); + Assert.False(capabilities.IsGenericWebDav); + } + + [Fact] + public void GenericWebDavCapabilities_Configuration_SetsCorrectly() { + // Arrange & Act + var capabilities = new ServerCapabilities { + IsNextcloud = false, + IsOcis = false, + ServerVersion = "1.0.0" + }; + + // Assert + Assert.False(capabilities.IsNextcloud); + Assert.False(capabilities.IsOcis); + Assert.True(capabilities.IsGenericWebDav); + Assert.Equal("1.0.0", capabilities.ServerVersion); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void ChunkingVersion_VariousVersions_SetsCorrectly(int version) { + // Arrange + var capabilities = new ServerCapabilities(); + + // Act + capabilities.ChunkingVersion = version; + + // Assert + Assert.Equal(version, capabilities.ChunkingVersion); + } + + [Theory] + [InlineData("25.0.0")] + [InlineData("26.0.1")] + [InlineData("27.0.0")] + [InlineData("28.0.1")] + [InlineData("29.0.0-beta1")] + public void ServerVersion_VariousVersionFormats_SetsCorrectly(string version) { + // Arrange + var capabilities = new ServerCapabilities(); + + // Act + capabilities.ServerVersion = version; + + // Assert + Assert.Equal(version, capabilities.ServerVersion); + } + + [Fact] + public void ServerVersion_EmptyString_SetsCorrectly() { + // Arrange + var capabilities = new ServerCapabilities { + ServerVersion = "1.0.0" + }; + + // Act + capabilities.ServerVersion = ""; + + // Assert + Assert.Equal("", capabilities.ServerVersion); + } + + [Fact] + public void AllProperties_CanBeSetIndependently() { + // Arrange + var capabilities = new ServerCapabilities(); + + // Act + capabilities.IsNextcloud = true; + capabilities.IsOcis = false; + capabilities.ServerVersion = "Test"; + capabilities.SupportsChunking = true; + capabilities.ChunkingVersion = 2; + capabilities.SupportsOcisChunking = false; + + // Assert + Assert.True(capabilities.IsNextcloud); + Assert.False(capabilities.IsOcis); + Assert.Equal("Test", capabilities.ServerVersion); + Assert.True(capabilities.SupportsChunking); + Assert.Equal(2, capabilities.ChunkingVersion); + Assert.False(capabilities.SupportsOcisChunking); + } + + [Fact] + public void IsGenericWebDav_DynamicallyUpdates_WhenPropertiesChange() { + // Arrange + var capabilities = new ServerCapabilities(); + + // Act & Assert - Initially generic WebDAV + Assert.True(capabilities.IsGenericWebDav); + + // Set as Nextcloud + capabilities.IsNextcloud = true; + Assert.False(capabilities.IsGenericWebDav); + + // Revert + capabilities.IsNextcloud = false; + Assert.True(capabilities.IsGenericWebDav); + + // Set as OCIS + capabilities.IsOcis = true; + Assert.False(capabilities.IsGenericWebDav); + + // Revert + capabilities.IsOcis = false; + Assert.True(capabilities.IsGenericWebDav); + } + + [Fact] + public void NextcloudWithChunking_Version2_SetsAllPropertiesCorrectly() { + // Arrange & Act + var capabilities = new ServerCapabilities { + IsNextcloud = true, + ServerVersion = "28.0.1", + SupportsChunking = true, + ChunkingVersion = 2, + SupportsOcisChunking = false + }; + + // Assert + Assert.True(capabilities.IsNextcloud); + Assert.False(capabilities.IsOcis); + Assert.True(capabilities.SupportsChunking); + Assert.Equal(2, capabilities.ChunkingVersion); + Assert.False(capabilities.SupportsOcisChunking); + Assert.False(capabilities.IsGenericWebDav); + } + + [Fact] + public void OcisWithTusProtocol_SetsAllPropertiesCorrectly() { + // Arrange & Act + var capabilities = new ServerCapabilities { + IsOcis = true, + ServerVersion = "5.0.0", + SupportsOcisChunking = true, + SupportsChunking = false, + ChunkingVersion = 0 + }; + + // Assert + Assert.True(capabilities.IsOcis); + Assert.False(capabilities.IsNextcloud); + Assert.True(capabilities.SupportsOcisChunking); + Assert.False(capabilities.SupportsChunking); + Assert.Equal(0, capabilities.ChunkingVersion); + Assert.False(capabilities.IsGenericWebDav); + } +} diff --git a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs index 8539423..af4daa2 100644 --- a/tests/SharpSync.Tests/Sync/SyncEngineTests.cs +++ b/tests/SharpSync.Tests/Sync/SyncEngineTests.cs @@ -236,8 +236,287 @@ public async Task ConflictDetected_Event_IsRaised() { // Act await _syncEngine.SynchronizeAsync(); - // Assert - This might not trigger in this simple case, + // Assert - This might not trigger in this simple case, // but the event handler is correctly set up Assert.False(conflictRaised); // No actual conflict in this simple test } + + [Fact] + public async Task SynchronizeAsync_MultipleFiles_SyncsAllFiles() { + // Arrange + var files = new[] { "file1.txt", "file2.txt", "file3.txt", "file4.txt", "file5.txt" }; + foreach (var file in files) { + var fullPath = Path.Combine(_localRootPath, file); + await File.WriteAllTextAsync(fullPath, $"Content of {file}"); + } + + // Act + var result = await _syncEngine.SynchronizeAsync(); + + // Assert + Assert.True(result.Success); + Assert.Equal(files.Length, result.TotalFilesProcessed); + Assert.Equal(0, result.FilesConflicted); + } + + [Fact] + public async Task SynchronizeAsync_WithSubdirectories_SyncsFiles() { + // Arrange + var dir1 = Path.Combine(_localRootPath, "subdir1"); + var dir2 = Path.Combine(_localRootPath, "subdir1", "subdir2"); + Directory.CreateDirectory(dir1); + Directory.CreateDirectory(dir2); + + await File.WriteAllTextAsync(Path.Combine(dir1, "file1.txt"), "content1"); + await File.WriteAllTextAsync(Path.Combine(dir2, "file2.txt"), "content2"); + await File.WriteAllTextAsync(Path.Combine(_localRootPath, "root.txt"), "root content"); + + // Act + var result = await _syncEngine.SynchronizeAsync(); + + // Assert + Assert.True(result.Success); + // Should sync 3 files + 2 directories = 5 items total + Assert.Equal(5, result.TotalFilesProcessed); + } + + [Fact] + public async Task SynchronizeAsync_DryRun_DoesNotModifyFiles() { + // Arrange + var filePath = Path.Combine(_localRootPath, "test.txt"); + await File.WriteAllTextAsync(filePath, "test content"); + + var options = new SyncOptions { + DryRun = true + }; + + // Act + var result = await _syncEngine.SynchronizeAsync(options); + + // Assert + Assert.True(result.Success); + // In dry run mode, files should be detected but not actually synced + var remoteFilePath = Path.Combine(_remoteRootPath, "test.txt"); + Assert.False(File.Exists(remoteFilePath)); // File should not exist in remote + } + + [Fact] + public async Task SynchronizeAsync_UpdateExisting_UpdatesModifiedFiles() { + // Arrange + var filePath = "update.txt"; + var localFullPath = Path.Combine(_localRootPath, filePath); + + // Initial sync + await File.WriteAllTextAsync(localFullPath, "original content"); + await _syncEngine.SynchronizeAsync(); + + // Modify the file + await File.WriteAllTextAsync(localFullPath, "updated content"); + await Task.Delay(100); // Ensure timestamp difference + + var options = new SyncOptions { + UpdateExisting = true + }; + + // Act + var result = await _syncEngine.SynchronizeAsync(options); + + // Assert + Assert.True(result.Success); + var remoteFullPath = Path.Combine(_remoteRootPath, filePath); + var remoteContent = await File.ReadAllTextAsync(remoteFullPath); + Assert.Equal("updated content", remoteContent); + } + + [Fact] + public async Task GetStatsAsync_AfterSync_ReturnsCorrectStats() { + // Arrange + var filePath = Path.Combine(_localRootPath, "stats.txt"); + await File.WriteAllTextAsync(filePath, "test"); + await _syncEngine.SynchronizeAsync(); + + // Act + var stats = await _syncEngine.GetStatsAsync(); + + // Assert + Assert.NotNull(stats); + Assert.True(stats.TotalItems > 0); + } + + [Fact] + public async Task PreviewSyncAsync_ReturnsExpectedChanges() { + // Arrange + var filePath = Path.Combine(_localRootPath, "preview.txt"); + await File.WriteAllTextAsync(filePath, "preview content"); + + // Act + var preview = await _syncEngine.PreviewSyncAsync(); + + // Assert + Assert.NotNull(preview); + // Preview should detect the new file + Assert.True(preview.TotalFilesProcessed > 0 || preview.FilesSkipped > 0); + } + + [Fact] + public async Task ResetSyncStateAsync_ClearsDatabase() { + // Arrange + var filePath = Path.Combine(_localRootPath, "reset.txt"); + await File.WriteAllTextAsync(filePath, "test"); + await _syncEngine.SynchronizeAsync(); + + var statsBefore = await _syncEngine.GetStatsAsync(); + Assert.True(statsBefore.TotalItems > 0); + + // Act + await _syncEngine.ResetSyncStateAsync(); + + // Assert + var statsAfter = await _syncEngine.GetStatsAsync(); + Assert.Equal(0, statsAfter.TotalItems); + } + + [Fact] + public async Task SynchronizeAsync_DeleteExtraneous_RemovesExtraFiles() { + // Arrange + var keepFile = Path.Combine(_localRootPath, "keep.txt"); + var deleteFile = Path.Combine(_remoteRootPath, "delete.txt"); + + await File.WriteAllTextAsync(keepFile, "keep this"); + await File.WriteAllTextAsync(deleteFile, "delete this"); + + var options = new SyncOptions { + DeleteExtraneous = true + }; + + // Act + var result = await _syncEngine.SynchronizeAsync(options); + + // Assert + Assert.True(result.Success); + Assert.False(File.Exists(deleteFile)); // Extra remote file should be deleted + Assert.True(File.Exists(Path.Combine(_remoteRootPath, "keep.txt"))); // Local file should be synced + } + + [Fact] + public async Task SynchronizeAsync_BothModified_CreatesConflict() { + // Arrange + var fileName = "conflict.txt"; + var localPath = Path.Combine(_localRootPath, fileName); + var remotePath = Path.Combine(_remoteRootPath, fileName); + + // Create initial file and sync + await File.WriteAllTextAsync(localPath, "initial"); + await _syncEngine.SynchronizeAsync(); + + // Modify both versions + await Task.Delay(100); + await File.WriteAllTextAsync(localPath, "local modification"); + await File.WriteAllTextAsync(remotePath, "remote modification"); + + _syncEngine.ConflictDetected += (sender, args) => { + args.Resolution = ConflictResolution.UseLocal; + }; + + // Act + var result = await _syncEngine.SynchronizeAsync(new SyncOptions { UpdateExisting = true }); + + // Assert + Assert.True(result.Success); + // Note: Conflict detection depends on the engine's implementation + // This test verifies the conflict handling mechanism is in place + } + + [Fact] + public async Task SynchronizeAsync_LargeFile_HandlesCorrectly() { + // Arrange + var filePath = Path.Combine(_localRootPath, "large.bin"); + var largeContent = new byte[1024 * 1024]; // 1 MB + new Random().NextBytes(largeContent); + await File.WriteAllBytesAsync(filePath, largeContent); + + // Act + var result = await _syncEngine.SynchronizeAsync(); + + // Assert + Assert.True(result.Success); + var remotePath = Path.Combine(_remoteRootPath, "large.bin"); + Assert.True(File.Exists(remotePath)); + var remoteContent = await File.ReadAllBytesAsync(remotePath); + Assert.Equal(largeContent.Length, remoteContent.Length); + } + + [Fact] + public async Task SynchronizeAsync_EmptyFile_HandlesCorrectly() { + // Arrange + var filePath = Path.Combine(_localRootPath, "empty.txt"); + await File.WriteAllTextAsync(filePath, ""); + + // Act + var result = await _syncEngine.SynchronizeAsync(); + + // Assert + Assert.True(result.Success); + var remotePath = Path.Combine(_remoteRootPath, "empty.txt"); + Assert.True(File.Exists(remotePath)); + } + + [Fact] + public async Task SynchronizeAsync_SpecialCharactersInFileName_HandlesCorrectly() { + // Arrange + var fileName = "file with spaces & special-chars_123.txt"; + var filePath = Path.Combine(_localRootPath, fileName); + await File.WriteAllTextAsync(filePath, "test content"); + + // Act + var result = await _syncEngine.SynchronizeAsync(); + + // Assert + Assert.True(result.Success); + var remotePath = Path.Combine(_remoteRootPath, fileName); + Assert.True(File.Exists(remotePath)); + } + + [Fact] + public async Task SynchronizeAsync_ChecksumOnly_UsesChecksums() { + // Arrange + var filePath = Path.Combine(_localRootPath, "checksum.txt"); + await File.WriteAllTextAsync(filePath, "checksum test"); + + var options = new SyncOptions { + ChecksumOnly = true + }; + + // Act + var result = await _syncEngine.SynchronizeAsync(options); + + // Assert + Assert.True(result.Success); + Assert.Equal(1, result.TotalFilesProcessed); + } + + [Fact] + public async Task SynchronizeAsync_ProgressReporting_ReportsCorrectly() { + // Arrange + for (int i = 0; i < 5; i++) { + var filePath = Path.Combine(_localRootPath, $"progress{i}.txt"); + await File.WriteAllTextAsync(filePath, $"content {i}"); + } + + var progressEvents = new List(); + _syncEngine.ProgressChanged += (sender, args) => { + progressEvents.Add(args); + }; + + // Act + var result = await _syncEngine.SynchronizeAsync(); + + // Assert + Assert.True(result.Success); + Assert.NotEmpty(progressEvents); + // Verify progress increases over time + var firstProgress = progressEvents[0].Progress.Percentage; + var lastProgress = progressEvents[^1].Progress.Percentage; + Assert.True(lastProgress >= firstProgress); + } }