This PR has a large blast radius because it takes the final step and bumps up the Controller to be fully async. We have historically done a piece-by-piece update, so all operations were already async but the controller interface was kept synchronous. With this update, the controller is now fully async and all tests are updated. Most places where the new C# compiler warns about function names not ending in `Async` were also adressed, giving a massive refactor change. Functionally, no changes are done.
1363 lines
56 KiB
C#
1363 lines
56 KiB
C#
// Copyright (C) 2026, The Duplicati Team
|
|
// https://duplicati.com, hello@duplicati.com
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a
|
|
// copy of this software and associated documentation files (the "Software"),
|
|
// to deal in the Software without restriction, including without limitation
|
|
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
// and/or sell copies of the Software, and to permit persons to whom the
|
|
// Software is furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
// DEALINGS IN THE SOFTWARE.
|
|
|
|
using Duplicati.Library.Main.Operation;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Main;
|
|
using NUnit.Framework;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Duplicati.Library.Main.Database;
|
|
using Duplicati.Library.SQLiteHelper;
|
|
using Assert = NUnit.Framework.Legacy.ClassicAssert;
|
|
using System.Threading.Tasks;
|
|
using System.IO;
|
|
|
|
namespace Duplicati.UnitTest
|
|
{
|
|
[TestFixture]
|
|
public class RemoteSynchronizationHandlerTests : BasicSetupHelper
|
|
{
|
|
const System.Reflection.BindingFlags BINDING_FLAGS = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance;
|
|
|
|
string source = "";
|
|
string dest1 = "";
|
|
string dest2 = "";
|
|
string dest3 = "";
|
|
|
|
[SetUp]
|
|
public void Setup()
|
|
{
|
|
// On Windows, we have to escape backslashes in paths for JSON strings
|
|
source = Path.Combine(TARGETFOLDER, "source").Replace("\\", "\\\\");
|
|
dest1 = Path.Combine(TARGETFOLDER, "dest1").Replace("\\", "\\\\");
|
|
dest2 = Path.Combine(TARGETFOLDER, "dest2").Replace("\\", "\\\\");
|
|
dest3 = Path.Combine(TARGETFOLDER, "dest3").Replace("\\", "\\\\");
|
|
}
|
|
|
|
private static void EnsureOperationTableCreated(Microsoft.Data.Sqlite.SqliteCommand cmd)
|
|
{
|
|
cmd.CommandText = @"
|
|
CREATE TABLE IF NOT EXISTS ""Operation"" (
|
|
""Description"" TEXT,
|
|
""Timestamp"" INTEGER
|
|
)";
|
|
cmd.ExecuteNonQuery();
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_SingleDestination()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
Assert.AreEqual(1, destinations.Count);
|
|
// Convert the escaped backslashes to single backslashes for comparison on Windows
|
|
Assert.AreEqual($"file://{dest1.Replace("\\\\", "\\")}", destinations[0].Config.Dst);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_MultipleDestinations()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}},
|
|
{{""url"": ""file://{dest2}""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
Assert.AreEqual(2, destinations.Count);
|
|
// Convert the escaped backslashes to single backslashes for comparison on Windows
|
|
Assert.AreEqual($"file://{dest1.Replace("\\\\", "\\")}", destinations[0].Config.Dst);
|
|
Assert.AreEqual($"file://{dest2.Replace("\\\\", "\\")}", destinations[1].Config.Dst);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_Modes()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""inline""}},
|
|
{{""url"": ""file://{dest2}"", ""mode"": ""interval""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
Assert.AreEqual(2, destinations.Count);
|
|
Assert.AreEqual(RemoteSyncTriggerMode.Inline, destinations[0].Mode);
|
|
Assert.AreEqual(RemoteSyncTriggerMode.Interval, destinations[1].Mode);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_Schedules()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""interval"": ""1h""}},
|
|
{{""url"": ""file://{dest2}"", ""interval"": ""2h""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
Assert.AreEqual(2, destinations.Count);
|
|
Assert.AreEqual(TimeSpan.FromHours(1), destinations[0].Interval);
|
|
Assert.AreEqual(TimeSpan.FromHours(2), destinations[1].Interval);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_Counts()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""count"": 5}},
|
|
{{""url"": ""file://{dest2}"", ""count"": 10}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
Assert.AreEqual(2, destinations.Count);
|
|
Assert.AreEqual(5, destinations[0].Count);
|
|
Assert.AreEqual(10, destinations[1].Count);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_SetsSource()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}"
|
|
};
|
|
|
|
var remoteurl = $"file://{source}";
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), new BackupResults());
|
|
|
|
var stored_source = handler.GetType().GetField("m_source", BINDING_FLAGS).GetValue(handler) as string;
|
|
|
|
Assert.AreEqual(remoteurl, stored_source);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_Inline()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""inline""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsTrue(shouldTrigger);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_Scheduled_FirstTime()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""interval"", ""interval"": ""1h""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table as we're now calling outside of the Controller
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsTrue(shouldTrigger);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_Counting_FirstTime()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""counting"", ""count"": 2}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table and simulate 2 backups
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
cmd.CommandText = @"
|
|
INSERT INTO ""Operation"" (
|
|
""Description"",
|
|
""Timestamp""
|
|
) VALUES (
|
|
'Backup',
|
|
@ts
|
|
)";
|
|
cmd.AddNamedParameter("@ts", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow));
|
|
cmd.ExecuteNonQuery();
|
|
cmd.ExecuteNonQuery();
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsTrue(shouldTrigger);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestRecordSyncOperation()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
cmd.ExecuteNonQuery();
|
|
|
|
handler.GetType().GetMethod("RecordSyncOperation", BINDING_FLAGS).Invoke(handler, [0]);
|
|
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" = 'Rsync 0'
|
|
";
|
|
var count = (long)cmd.ExecuteScalar();
|
|
Assert.AreEqual(1, count);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_SuccessfulBackup_TriggersSyncAsync()
|
|
{
|
|
// Create some test files
|
|
System.IO.File.WriteAllText(System.IO.Path.Combine(DATAFOLDER, "testfile.txt"), "test content");
|
|
|
|
var options = TestOptions;
|
|
options["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}";
|
|
options["dbpath"] = DBFILE;
|
|
|
|
// Run actual backup
|
|
using var console = new CommandLine.ConsoleOutput(Console.Out, options);
|
|
using var controller = new Controller($"file://{TARGETFOLDER}", options, console);
|
|
var result = await controller.BackupAsync([DATAFOLDER]);
|
|
|
|
// Now check if sync was triggered by checking if Operation table
|
|
// has sync entry
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" LIKE 'Rsync %'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(1, count);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_FailedBackup_SkipsSync_Async()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var remoteurl = $"file://{source}";
|
|
var results = new BasicBackupResults(ParsedResultType.Error);
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), results);
|
|
|
|
// Create Operation table
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
await handler.RunAsync();
|
|
|
|
// Check if sync was not recorded
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" = 'Rsync 0'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(0, count);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_InvalidMode_DefaultsToInline()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""inline""}},
|
|
{{""url"": ""file://{dest2}"", ""mode"": ""invalidmode""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
Assert.AreEqual(2, destinations.Count);
|
|
Assert.AreEqual(RemoteSyncTriggerMode.Inline, destinations[0].Mode);
|
|
Assert.AreEqual(RemoteSyncTriggerMode.Inline, destinations[1].Mode); // Invalid defaults to Inline
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_InvalidSchedule_DefaultsToNull()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""interval"": ""1h""}},
|
|
{{""url"": ""file://{dest2}"", ""interval"": ""invalid""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
Assert.AreEqual(2, destinations.Count);
|
|
Assert.AreEqual(TimeSpan.FromHours(1), destinations[0].Interval);
|
|
Assert.AreEqual(null, destinations[1].Interval); // Invalid defaults to null
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_InvalidCount_Fails()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""count"": 5}},
|
|
{{""url"": ""file://{dest2}"", ""count"": ""invalid""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
// Deserializing the json should fail for the invalid count, resulting in no destinations being configured
|
|
Assert.AreEqual(0, destinations.Count);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_SyncOnWarnings()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""sync-on-warnings"": false, ""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var syncOnWarnings = handler.GetType().GetField("m_syncOnWarnings", BINDING_FLAGS).GetValue(handler);
|
|
Assert.IsFalse((bool)syncOnWarnings);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_Scheduled_Initial()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""interval"", ""interval"": ""1h""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table with no prior syncs
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsTrue(shouldTrigger);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_Scheduled_NotDue()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""interval"", ""interval"": ""1h""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table and insert recent sync
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
cmd.CommandText = @"
|
|
INSERT INTO ""Operation"" (
|
|
""Description"",
|
|
""Timestamp""
|
|
) VALUES (
|
|
'Rsync 0',
|
|
@ts
|
|
)";
|
|
cmd.AddNamedParameter("@ts", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow));
|
|
cmd.ExecuteNonQuery();
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsFalse(shouldTrigger);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_Scheduled_Due()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""interval"", ""interval"": ""1h""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table and insert recent sync
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
cmd.CommandText = @"
|
|
INSERT INTO ""Operation"" (
|
|
""Description"",
|
|
""Timestamp""
|
|
) VALUES (
|
|
'Rsync 0',
|
|
@ts
|
|
)";
|
|
cmd.AddNamedParameter("@ts", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow - TimeSpan.FromHours(2)));
|
|
cmd.ExecuteNonQuery();
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsTrue(shouldTrigger);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_Counting_Initial()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""counting"", ""count"": 5}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table and simulate 0 backups, which should trigger sync
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsTrue(shouldTrigger);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_Counting_NotReached()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""counting"", ""count"": 5}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table and simulate 2 backups (less than 5)
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd_backup = db.CreateCommand();
|
|
using var cmd_rsync = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd_backup);
|
|
|
|
cmd_rsync.CommandText = @"
|
|
INSERT INTO ""Operation"" (
|
|
""Description"",
|
|
""Timestamp""
|
|
) VALUES (
|
|
'Rsync 0',
|
|
@ts
|
|
)";
|
|
cmd_rsync.AddNamedParameter("@ts");
|
|
|
|
cmd_backup.CommandText = @"
|
|
INSERT INTO ""Operation"" (
|
|
""Description"",
|
|
""Timestamp""
|
|
) VALUES (
|
|
'Backup',
|
|
@ts
|
|
)";
|
|
cmd_backup.AddNamedParameter("@ts");
|
|
|
|
var curtime = Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow);
|
|
cmd_backup.SetParameterValue("@ts", curtime - 5);
|
|
cmd_backup.ExecuteNonQuery();
|
|
cmd_rsync.SetParameterValue("@ts", curtime - 4);
|
|
cmd_rsync.ExecuteNonQuery();
|
|
cmd_backup.SetParameterValue("@ts", curtime - 3);
|
|
cmd_backup.ExecuteNonQuery();
|
|
cmd_backup.SetParameterValue("@ts", curtime - 2);
|
|
cmd_backup.ExecuteNonQuery();
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsFalse(shouldTrigger);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_Counting_Reached()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""counting"", ""count"": 2}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table and simulate 2 backups (less than 5)
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd_backup = db.CreateCommand();
|
|
using var cmd_rsync = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd_backup);
|
|
|
|
cmd_rsync.CommandText = @"
|
|
INSERT INTO ""Operation"" (
|
|
""Description"",
|
|
""Timestamp""
|
|
) VALUES (
|
|
'Rsync 0',
|
|
@ts
|
|
)";
|
|
cmd_rsync.AddNamedParameter("@ts");
|
|
|
|
cmd_backup.CommandText = @"
|
|
INSERT INTO ""Operation"" (
|
|
""Description"",
|
|
""Timestamp""
|
|
) VALUES (
|
|
'Backup',
|
|
@ts
|
|
)";
|
|
cmd_backup.AddNamedParameter("@ts");
|
|
|
|
var curtime = Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow);
|
|
cmd_backup.SetParameterValue("@ts", curtime - 5);
|
|
cmd_backup.ExecuteNonQuery();
|
|
cmd_rsync.SetParameterValue("@ts", curtime - 4);
|
|
cmd_rsync.ExecuteNonQuery();
|
|
cmd_backup.SetParameterValue("@ts", curtime - 3);
|
|
cmd_backup.ExecuteNonQuery();
|
|
cmd_backup.SetParameterValue("@ts", curtime - 2);
|
|
cmd_backup.ExecuteNonQuery();
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsTrue(shouldTrigger);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_MissingDbPath_ReturnsFalse()
|
|
{
|
|
// This case shouldn't occur in normal operation, as a backup will
|
|
// always have an associated database. However, if such a state
|
|
// should occur, we want to ensure the handler doesn't trigger a
|
|
// sync, as the state is likely errornous.
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""inline""}}
|
|
]}}"
|
|
// No dbpath
|
|
};
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [0, destinations[0]]);
|
|
|
|
Assert.IsTrue(shouldTrigger); // Inline always returns true
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_NonBackupOperation_SkipsSync_Async()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var remoteurl = $"file://{source}";
|
|
var results = new BasicBackupResults(ParsedResultType.Success);
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), results);
|
|
|
|
await handler.RunAsync();
|
|
|
|
// Check if sync was not recorded
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" = 'Rsync 0'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(0, count);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_FatalResult_SkipsSync_Async()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
var remoteurl = $"file://{source}";
|
|
var results = new BasicBackupResults(ParsedResultType.Fatal);
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), results);
|
|
|
|
await handler.RunAsync();
|
|
|
|
// Check if sync was not recorded
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" = 'Rsync 0'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(0, count);
|
|
}
|
|
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestRecordSyncOperation_MissingDbPath_NoCrash()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}"
|
|
// No dbpath
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Should not throw exception
|
|
Assert.DoesNotThrow(() => handler.GetType().GetMethod("RecordSyncOperation", BINDING_FLAGS).Invoke(handler, [0]));
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_DefaultMode()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}},
|
|
{{""url"": ""file://{dest2}"", ""mode"": ""interval""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
Assert.AreEqual(2, destinations.Count);
|
|
Assert.AreEqual(RemoteSyncTriggerMode.Inline, destinations[0].Mode); // Default
|
|
Assert.AreEqual(RemoteSyncTriggerMode.Interval, destinations[1].Mode);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_WithEmptyDestinations_SkipsSync_Async()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}},
|
|
{{""url"": """"}},
|
|
{{""url"": ""file://{dest2}""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var remoteurl = $"file://{source}/";
|
|
var results = new BasicBackupResults(ParsedResultType.Success);
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), results);
|
|
|
|
// Create Operation table
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
await handler.RunAsync();
|
|
|
|
// Check that two syncs were recorded (skipping the empty one)
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description""
|
|
LIKE 'Rsync %'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(2, count); // Two valid destinations, empty skipped
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestShouldTriggerSync_OutOfRangeIndex_ReturnsFalse()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""interval""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
var shouldTrigger = (bool)handler.GetType().GetMethod("ShouldTriggerSync", BINDING_FLAGS).Invoke(handler, [1, destinations[0]]); // Index 1, but only 1 destination
|
|
|
|
Assert.IsFalse(shouldTrigger); // Out of range
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_WithMultipleDestinations_SelectiveSync_Async()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}"", ""mode"": ""inline""}},
|
|
{{""url"": ""file://{dest2}"", ""mode"": ""interval"", ""interval"": ""1h""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
var remoteurl = $"file://{source}";
|
|
var results = new BasicBackupResults(ParsedResultType.Success);
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), results);
|
|
|
|
// Create Operation table and insert recent sync for dest2
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
cmd.CommandText = @"
|
|
INSERT INTO ""Operation"" (
|
|
""Description"",
|
|
""Timestamp""
|
|
) VALUES (
|
|
'Rsync 1',
|
|
@ts
|
|
)";
|
|
cmd.AddNamedParameter("@ts", Library.Utility.Utility.NormalizeDateTimeToEpochSeconds(DateTime.UtcNow));
|
|
await cmd.ExecuteNonQueryAsync();
|
|
|
|
await handler.RunAsync();
|
|
|
|
// Check that one additional sync was recorded (dest1 inline, dest2 interval not due)
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" LIKE 'Rsync %'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(2, count); // Initial 1 + 1 new = 2
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_WithWarningResult_TriggersSync_Async()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var remoteurl = $"file://{source}";
|
|
var result = new BasicBackupResults(ParsedResultType.Warning);
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), result);
|
|
|
|
// Create Operation table
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
await handler.RunAsync();
|
|
|
|
// Check that sync was recorded
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" = 'Rsync 0'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(1, count);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_WithWarningResult_SkipsSync_WhenDisabled_Async()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""sync-on-warnings"": false, ""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
var remoteurl = $"file://{source}";
|
|
var result = new BasicBackupResults(ParsedResultType.Warning);
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), result);
|
|
|
|
// Create Operation table
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
await handler.RunAsync();
|
|
|
|
// Check that sync was not recorded
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" = 'Rsync 0'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(0, count);
|
|
}
|
|
|
|
//TODO format destinations
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_WithWhitespaceInDestinations_PreservesWhitespace()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @$"{{""destinations"": [
|
|
{{""url"": "" file://{dest1} ""}},
|
|
{{""url"": "" file://{dest2} ""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
Assert.AreEqual(2, destinations.Count);
|
|
// Convert the escaped backslashes to single backslashes for comparison on Windows
|
|
Assert.AreEqual($" file://{dest1.Replace("\\\\", "\\")} ", destinations[0].Config.Dst);
|
|
Assert.AreEqual($" file://{dest2.Replace("\\\\", "\\")} ", destinations[1].Config.Dst);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_WithEmptyDestinationsInList_Skips()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}},
|
|
{{}},
|
|
{{""url"": ""file://{dest2}""}}
|
|
]}}"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var destinations = handler.GetType().GetField("m_destinations", BINDING_FLAGS).GetValue(handler) as List<RemoteSyncDestinationConfig>;
|
|
|
|
Assert.AreEqual(3, destinations.Count); // All are included, empty ones skipped in RunAsync
|
|
// Convert the escaped backslashes to single backslashes for comparison on Windows
|
|
Assert.AreEqual($"file://{dest1.Replace("\\\\", "\\")}", destinations[0].Config.Dst);
|
|
Assert.AreEqual("", destinations[1].Config.Dst);
|
|
Assert.AreEqual($"file://{dest2.Replace("\\\\", "\\")}", destinations[2].Config.Dst);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestRecordSyncOperation_WithDifferentIndex()
|
|
{
|
|
// Incorrect index should not record, but throw a warning.
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Create Operation table
|
|
using var db = SQLiteLoader.LoadConnection(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
handler.GetType().GetMethod("RecordSyncOperation", BINDING_FLAGS).Invoke(handler, [2]);
|
|
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" = 'Rsync 2'
|
|
";
|
|
var count = (long)cmd.ExecuteScalar();
|
|
Assert.AreEqual(0, count);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestRecordSyncOperation_WithMissingTable_ThrowsException()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
// Do not create Operation table
|
|
|
|
Assert.Throws<System.Reflection.TargetInvocationException>(() =>
|
|
handler.GetType().GetMethod("RecordSyncOperation", BINDING_FLAGS).Invoke(handler, [0]));
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public void TestConfigure_InvalidJson_DoesNotActivate()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = @"invalid json"
|
|
};
|
|
|
|
var handler = new RemoteSynchronizationHandler($"file://{source}", new Options(options), new BackupResults());
|
|
|
|
var enabled = handler.GetType().GetField("m_enabled", BINDING_FLAGS).GetValue(handler);
|
|
Assert.IsFalse((bool)enabled);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_WithNoDestinations_DoesNotActivate_Async()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
// No remote-sync-json-config
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var remoteurl = $"file://{source}";
|
|
var results = new BasicBackupResults(ParsedResultType.Success);
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), results);
|
|
|
|
var enabled = handler.GetType().GetField("m_enabled", BINDING_FLAGS).GetValue(handler);
|
|
Assert.IsFalse((bool)enabled);
|
|
|
|
// Create Operation table
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
|
|
await handler.RunAsync();
|
|
|
|
// Check that no sync was recorded
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description""
|
|
LIKE 'Rsync %'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(0, count);
|
|
}
|
|
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestRunAsync_ForNonBackupOperations_SkipsSync_Async()
|
|
{
|
|
var options = new Dictionary<string, string>
|
|
{
|
|
["remote-sync-json-config"] = $@"{{""destinations"": [
|
|
{{""url"": ""file://{dest1}""}}
|
|
]}}",
|
|
["dbpath"] = DBFILE
|
|
};
|
|
|
|
var remoteurl = $"file://{source}";
|
|
var results = new BasicBackupResults(ParsedResultType.Success);
|
|
var handler = new RemoteSynchronizationHandler(remoteurl, new Options(options), results);
|
|
|
|
await handler.RunAsync();
|
|
|
|
// Check if sync was not recorded
|
|
using var db = await SQLiteLoader.LoadConnectionAsync(DBFILE);
|
|
using var cmd = db.CreateCommand();
|
|
EnsureOperationTableCreated(cmd);
|
|
cmd.CommandText = @"
|
|
SELECT COUNT(*)
|
|
FROM ""Operation""
|
|
WHERE ""Description"" = 'Rsync 0'
|
|
";
|
|
var count = (long)await cmd.ExecuteScalarAsync();
|
|
|
|
Assert.AreEqual(0, count);
|
|
}
|
|
|
|
[Test]
|
|
[Category("RemoteSync")]
|
|
public async Task TestMultipleBackups_WithCountingDestinationsAsync()
|
|
{
|
|
// Create test files: one folder with 2 files, 2 subfolders each with 2 files, all ~1kb
|
|
var testDataFolder = System.IO.Path.Combine(DATAFOLDER, "testdata");
|
|
System.IO.Directory.CreateDirectory(testDataFolder);
|
|
var sub1 = System.IO.Path.Combine(testDataFolder, "sub1");
|
|
var sub2 = System.IO.Path.Combine(testDataFolder, "sub2");
|
|
System.IO.Directory.CreateDirectory(sub1);
|
|
System.IO.Directory.CreateDirectory(sub2);
|
|
|
|
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
var rng = new Random();
|
|
var buf = new byte[1024];
|
|
void NewRandomFile(string folder, string filename)
|
|
{
|
|
rng.NextBytes(buf);
|
|
var content = new string([.. buf.Select(c => chars[c % chars.Length])]);
|
|
System.IO.File.WriteAllText(System.IO.Path.Combine(folder, filename), content);
|
|
}
|
|
NewRandomFile(testDataFolder, "file1.txt");
|
|
NewRandomFile(testDataFolder, "file2.txt");
|
|
NewRandomFile(sub1, "file3.txt");
|
|
NewRandomFile(sub1, "file4.txt");
|
|
NewRandomFile(sub2, "file5.txt");
|
|
NewRandomFile(sub2, "file6.txt");
|
|
|
|
// Sync destinations
|
|
var syncDest1 = System.IO.Path.Combine(BASEFOLDER, "syncdest1");
|
|
var syncDest2 = System.IO.Path.Combine(BASEFOLDER, "syncdest2");
|
|
var syncDest3 = System.IO.Path.Combine(BASEFOLDER, "syncdest3");
|
|
// Create the folders so that the destination exists.
|
|
if (!System.IO.Directory.Exists(syncDest1))
|
|
System.IO.Directory.CreateDirectory(syncDest1);
|
|
if (!System.IO.Directory.Exists(syncDest2))
|
|
System.IO.Directory.CreateDirectory(syncDest2);
|
|
if (!System.IO.Directory.Exists(syncDest3))
|
|
System.IO.Directory.CreateDirectory(syncDest3);
|
|
|
|
var options = TestOptions;
|
|
options["remote-sync-json-config"] = $@"
|
|
{{
|
|
""destinations"": [
|
|
{{
|
|
""url"": ""file://{syncDest1.Replace("\\", "\\\\")}""
|
|
}},
|
|
{{
|
|
""url"": ""file://{syncDest2.Replace("\\", "\\\\")}"",
|
|
""mode"": ""counting"",
|
|
""count"": 2
|
|
}},
|
|
{{
|
|
""url"": ""file://{syncDest3.Replace("\\", "\\\\")}"",
|
|
""mode"": ""counting"",
|
|
""count"": 3
|
|
}}
|
|
]
|
|
}}";
|
|
options["dbpath"] = DBFILE;
|
|
|
|
// First backup
|
|
async Task BackupAsync()
|
|
{
|
|
await Task.Delay(1000); // Ensure timestamp difference between backups
|
|
using var console = new CommandLine.ConsoleOutput(Console.Out, options);
|
|
using var controller = new Controller("file://" + TARGETFOLDER, options, console);
|
|
var result = await controller.BackupAsync([DATAFOLDER]);
|
|
Assert.AreEqual(ParsedResultType.Success, result.ParsedResult);
|
|
}
|
|
await BackupAsync();
|
|
|
|
// Assert all destinations have same files
|
|
bool ContainsSameFiles(string path1, string path2, bool shouldAssert = true)
|
|
{
|
|
bool matches = true;
|
|
var files1 = System.IO.Directory.GetFiles(path1, "*", System.IO.SearchOption.TopDirectoryOnly)
|
|
.Select(f => f.Substring(path1.Length).TrimStart(System.IO.Path.DirectorySeparatorChar))
|
|
.OrderBy(f => f)
|
|
.ToArray();
|
|
var files2 = System.IO.Directory.GetFiles(path2, "*", System.IO.SearchOption.TopDirectoryOnly)
|
|
.Select(f => f.Substring(path2.Length).TrimStart(System.IO.Path.DirectorySeparatorChar))
|
|
.OrderBy(f => f)
|
|
.ToArray();
|
|
|
|
if (shouldAssert)
|
|
Assert.AreEqual(files1.Length, files2.Length, $"File count mismatch between {path1} and {path2}");
|
|
matches &= files1.Length == files2.Length;
|
|
if (!matches)
|
|
return false;
|
|
|
|
for (var i = 0; i < files1.Length; i++)
|
|
{
|
|
if (shouldAssert)
|
|
Assert.AreEqual(files1[i], files2[i], $"Filename mismatch: {files1[i]} vs {files2[i]}");
|
|
matches &= files1[i] == files2[i];
|
|
|
|
var content1 = System.IO.File.ReadAllText(System.IO.Path.Combine(path1, files1[i]));
|
|
var content2 = System.IO.File.ReadAllText(System.IO.Path.Combine(path2, files2[i]));
|
|
if (shouldAssert)
|
|
Assert.AreEqual(content1, content2, $"File content mismatch for {files1[i]}");
|
|
matches &= content1 == content2;
|
|
}
|
|
|
|
var folders1 = System.IO.Directory.GetDirectories(path1, "*", System.IO.SearchOption.TopDirectoryOnly)
|
|
.Select(f => f.Substring(path1.Length).TrimStart(System.IO.Path.DirectorySeparatorChar))
|
|
.OrderBy(f => f)
|
|
.ToArray();
|
|
var folders2 = System.IO.Directory.GetDirectories(path2, "*", System.IO.SearchOption.TopDirectoryOnly)
|
|
.Select(f => f.Substring(path2.Length).TrimStart(System.IO.Path.DirectorySeparatorChar))
|
|
.OrderBy(f => f)
|
|
.ToArray();
|
|
if (shouldAssert)
|
|
Assert.AreEqual(folders1.Length, folders2.Length, $"Folder count mismatch between {path1} and {path2}");
|
|
matches &= folders1.Length == folders2.Length;
|
|
if (!matches)
|
|
return false;
|
|
|
|
for (int i = 0; i < folders1.Length; i++)
|
|
{
|
|
if (shouldAssert)
|
|
Assert.AreEqual(folders1[i], folders2[i], $"Folder name mismatch: {folders1[i]} vs {folders2[i]}");
|
|
matches &= folders1[i] == folders2[i];
|
|
|
|
// Recursively check subfolders
|
|
matches &= ContainsSameFiles(System.IO.Path.Combine(path1, folders1[i]), System.IO.Path.Combine(path2, folders2[i]), shouldAssert);
|
|
}
|
|
|
|
return matches;
|
|
}
|
|
|
|
bool DestinationsAreEqual(bool shouldAssert = true, params string[] paths)
|
|
{
|
|
var matches = true;
|
|
for (var i = 1; i < paths.Length; i++)
|
|
matches &= ContainsSameFiles(paths[0], paths[i], shouldAssert);
|
|
return matches;
|
|
}
|
|
|
|
DestinationsAreEqual(true, TARGETFOLDER, syncDest1, syncDest2, syncDest3);
|
|
|
|
// Assert restore from each works
|
|
async Task AssertRestoreWorksAsync(string[] Urls)
|
|
{
|
|
var restoreFolders = Urls.Select(url => System.IO.Path.Combine(BASEFOLDER, "restore_" + System.IO.Path.GetRandomFileName())).ToArray();
|
|
foreach (var (url, restoreFolder) in Urls.Zip(restoreFolders))
|
|
{
|
|
System.IO.Directory.CreateDirectory(restoreFolder);
|
|
var dataFolder = System.IO.Path.Combine(restoreFolder, "data");
|
|
System.IO.Directory.CreateDirectory(dataFolder);
|
|
|
|
var restoreOptions = new Dictionary<string, string>(TestOptions)
|
|
{
|
|
["dbpath"] = System.IO.Path.Combine(restoreFolder, "db.sqlite"),
|
|
["restore-path"] = dataFolder
|
|
};
|
|
|
|
using var console = new CommandLine.ConsoleOutput(Console.Out, restoreOptions);
|
|
using var controller = new Controller(url, restoreOptions, console);
|
|
var result = await controller.RestoreAsync(["*"]);
|
|
}
|
|
|
|
DestinationsAreEqual(true, [.. restoreFolders.Select(f => System.IO.Path.Combine(f, "data"))]);
|
|
|
|
}
|
|
|
|
await AssertRestoreWorksAsync([TARGETFOLDER, syncDest1, syncDest2, syncDest3]);
|
|
|
|
// Add another file and backup
|
|
NewRandomFile(testDataFolder, "file7.txt");
|
|
await BackupAsync();
|
|
DestinationsAreEqual(true, TARGETFOLDER, syncDest1);
|
|
Assert.IsFalse(DestinationsAreEqual(false, TARGETFOLDER, syncDest2)); // syncDest2 should be out of date
|
|
Assert.IsFalse(DestinationsAreEqual(false, TARGETFOLDER, syncDest3)); // syncDest3 should be out of date
|
|
await AssertRestoreWorksAsync([TARGETFOLDER, syncDest1]);
|
|
|
|
// Add another file and backup
|
|
NewRandomFile(sub1, "file8.txt");
|
|
await BackupAsync();
|
|
DestinationsAreEqual(true, TARGETFOLDER, syncDest1, syncDest2);
|
|
Assert.IsFalse(DestinationsAreEqual(false, TARGETFOLDER, syncDest3)); // syncDest3 should be out of date
|
|
await AssertRestoreWorksAsync([TARGETFOLDER, syncDest1, syncDest2]);
|
|
|
|
// Add another file and backup
|
|
NewRandomFile(sub2, "file9.txt");
|
|
await BackupAsync();
|
|
DestinationsAreEqual(true, TARGETFOLDER, syncDest1, syncDest3);
|
|
Assert.IsFalse(DestinationsAreEqual(false, TARGETFOLDER, syncDest2)); // syncDest2 should be out of date
|
|
await AssertRestoreWorksAsync([TARGETFOLDER, syncDest1, syncDest3]);
|
|
}
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simple implementation of BackupResults for testing.
|
|
/// </summary>
|
|
internal class BasicBackupResults : BackupResults
|
|
{
|
|
public BasicBackupResults(ParsedResultType parsedResult)
|
|
{
|
|
_parsedResult = parsedResult;
|
|
}
|
|
|
|
private ParsedResultType _parsedResult;
|
|
|
|
public override ParsedResultType ParsedResult => _parsedResult;
|
|
}
|
|
}
|