2026-01-08 20:27:40 +01:00
// Copyright (C) 2025, 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.Modules.Builtin ;
2026-01-08 20:50:08 +01:00
using Duplicati.Library.Modules.Builtin.Strings ;
2026-01-08 20:27:40 +01:00
using Duplicati.Library.Interface ;
using Duplicati.Library.Utility ;
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 ;
2026-01-19 09:29:07 +01:00
using System.Threading.Tasks ;
2026-01-28 09:12:27 +01:00
using System.IO ;
2026-01-08 20:27:40 +01:00
namespace Duplicati.UnitTest
{
[TestFixture]
public class RemoteSynchronizationModuleTests : BasicSetupHelper
{
2026-01-09 10:50:09 +01:00
const System . Reflection . BindingFlags BINDING_FLAGS = System . Reflection . BindingFlags . NonPublic | System . Reflection . BindingFlags . Instance ;
2026-01-28 09:12:27 +01:00
RemoteSynchronizationModule module ;
string source = "" ;
string dest1 = "" ;
string dest2 = "" ;
string dest3 = "" ;
[SetUp]
public void Setup ()
{
module = new RemoteSynchronizationModule ();
2026-01-28 12:09:10 +01:00
// 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 ( "\\" , "\\\\" );
2026-01-28 09:12:27 +01:00
}
2026-01-09 10:50:09 +01:00
private static void EnsureOperationTableCreated ( Microsoft . Data . Sqlite . SqliteCommand cmd )
{
cmd . CommandText = @"
CREATE TABLE IF NOT EXISTS ""Operation"" (
""Description"" TEXT,
""Timestamp"" INTEGER
)" ;
cmd . ExecuteNonQuery ();
}
2026-01-08 20:27:40 +01:00
[Test]
[Category("RemoteSync")]
public void TestConfigure_SingleDestination ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}"
2026-01-08 20:27:40 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
2026-01-08 20:27:40 +01:00
Assert . AreEqual ( 1 , destinations . Count );
2026-01-28 12:09:10 +01:00
// Convert the escaped backslashes to single backslashes for comparison on Windows
Assert . AreEqual ( $"file://{dest1.Replace(" \\\\ ", " \\ ")}" , destinations [ 0 ]. Config . Dst );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_MultipleDestinations ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": ""file://{dest1}""}}, {{""url"": ""file://{dest2}""}}]}}"
2026-01-08 20:27:40 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
2026-01-08 20:27:40 +01:00
Assert . AreEqual ( 2 , destinations . Count );
2026-01-28 12:09:10 +01:00
// 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 );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_Modes ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""inline""}}, {{""url"": ""file://{dest2}"", ""mode"": ""interval""}}]}}"
2026-01-08 20:27:40 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
Assert . AreEqual ( 2 , destinations . Count );
Assert . AreEqual ( RemoteSyncTriggerMode . Inline , destinations [ 0 ]. Mode );
2026-01-28 05:38:59 +01:00
Assert . AreEqual ( RemoteSyncTriggerMode . Interval , destinations [ 1 ]. Mode );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_Schedules ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": ""file://{dest1}"", ""interval"": ""1h""}}, {{""url"": ""file://{dest2}"", ""interval"": ""2h""}}]}}"
2026-01-08 20:27:40 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
Assert . AreEqual ( 2 , destinations . Count );
2026-01-28 05:38:59 +01:00
Assert . AreEqual ( TimeSpan . FromHours ( 1 ), destinations [ 0 ]. Interval );
Assert . AreEqual ( TimeSpan . FromHours ( 2 ), destinations [ 1 ]. Interval );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_Counts ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": ""file://{dest1}"", ""count"": 5}}, {{""url"": ""file://{dest2}"", ""count"": 10}}]}}"
2026-01-08 20:27:40 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
Assert . AreEqual ( 2 , destinations . Count );
Assert . AreEqual ( 5 , destinations [ 0 ]. Count );
Assert . AreEqual ( 10 , destinations [ 1 ]. Count );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestOnStart_SetsSource ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}"
2026-01-08 20:27:40 +01:00
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:27:40 +01:00
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
2026-01-28 09:12:27 +01:00
var stored_source = module . GetType (). GetField ( "m_source" , BINDING_FLAGS ). GetValue ( module ) as string ;
Assert . AreEqual ( $"file://{source}" , stored_source );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Inline ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""inline""}}]}}" ,
2026-01-08 20:27:40 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
2026-01-08 20:27:40 +01:00
Assert . IsTrue ( shouldTrigger );
}
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Scheduled_FirstTime ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""interval"", ""interval"": ""1h""}}]}}" ,
2026-01-08 20:27:40 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-09 10:57:54 +01:00
// Create Operation table as we're now calling outside of the Controller
2026-01-08 20:27:40 +01:00
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
2026-01-08 20:27:40 +01:00
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
2026-01-08 20:27:40 +01:00
Assert . IsTrue ( shouldTrigger );
}
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Counting_FirstTime ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""counting"", ""count"": 2}}]}}" ,
2026-01-08 20:27:40 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
// Create Operation table and simulate 2 backups
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
cmd . CommandText = @"
INSERT INTO ""Operation"" (
""Description"",
""Timestamp""
) VALUES (
'Backup',
@ts
)" ;
cmd . AddNamedParameter ( "@ts" , Library . Utility . Utility . NormalizeDateTimeToEpochSeconds ( DateTime . UtcNow ));
2026-01-08 20:27:40 +01:00
cmd . ExecuteNonQuery ();
cmd . ExecuteNonQuery ();
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
2026-01-08 20:27:40 +01:00
Assert . IsTrue ( shouldTrigger );
}
[Test]
[Category("RemoteSync")]
public void TestRecordSyncOperation ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-08 20:27:40 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
// Create Operation table
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
2026-01-08 20:27:40 +01:00
cmd . ExecuteNonQuery ();
2026-01-09 10:50:09 +01:00
module . GetType (). GetMethod ( "RecordSyncOperation" , BINDING_FLAGS ). Invoke ( module , [ 0 ]);
2026-01-08 20:27:40 +01:00
2026-01-09 10:50:09 +01:00
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Rsync 0'
" ;
2026-01-08 20:27:40 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 1 , count );
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_SuccessfulBackup_TriggersSync ()
{
// Create some test files
System . IO . File . WriteAllText ( System . IO . Path . Combine ( DATAFOLDER , "testfile.txt" ), "test content" );
var options = TestOptions ;
2026-01-28 09:12:27 +01:00
options [ "remote-sync-json-config" ] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ;
2026-01-08 20:27:40 +01:00
options [ "dbpath" ] = DBFILE ;
// Run actual backup
2026-01-09 10:50:09 +01:00
using var console = new CommandLine . ConsoleOutput ( Console . Out , options );
2026-01-28 09:12:27 +01:00
using var controller = new Controller ( $"file://{TARGETFOLDER}" , options , console );
2026-01-09 10:50:09 +01:00
var result = controller . Backup ([ DATAFOLDER ]);
2026-01-08 20:27:40 +01:00
2026-01-09 10:50:09 +01:00
// Now check if sync was triggered (assuming module was loaded)
// But since we can't easily get the module instance, check if Operation table has sync entry
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" LIKE 'Rsync %'
" ;
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 1 , count );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_FailedBackup_SkipsSync ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-08 20:27:40 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:27:40 +01:00
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
// Create Operation table
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
2026-01-08 20:27:40 +01:00
var result = new TestBasicResults ( ParsedResultType . Error );
module . OnFinish ( result , null );
// Check if sync was not recorded
2026-01-09 10:50:09 +01:00
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Rsync 0'
" ;
2026-01-08 20:27:40 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 0 , count );
}
2026-01-08 20:33:32 +01:00
[Test]
[Category("RemoteSync")]
public void TestConfigure_InvalidMode_DefaultsToInline ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""inline""}}, {{""url"": ""file://{dest2}"", ""mode"": ""invalidmode""}}]}}"
2026-01-08 20:33:32 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) 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
2026-01-08 20:33:32 +01:00
}
[Test]
[Category("RemoteSync")]
2026-01-12 14:22:10 +01:00
public void TestConfigure_InvalidSchedule_DefaultsToNull ()
2026-01-08 20:33:32 +01:00
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""interval"": ""1h""}}, {{""url"": ""file://{dest2}"", ""interval"": ""invalid""}}]}}"
2026-01-08 20:33:32 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
Assert . AreEqual ( 2 , destinations . Count );
2026-01-28 05:38:59 +01:00
Assert . AreEqual ( TimeSpan . FromHours ( 1 ), destinations [ 0 ]. Interval );
Assert . AreEqual ( null , destinations [ 1 ]. Interval ); // Invalid defaults to null
2026-01-08 20:33:32 +01:00
}
[Test]
[Category("RemoteSync")]
2026-01-28 09:12:27 +01:00
public void TestConfigure_InvalidCount_Fails ()
2026-01-08 20:33:32 +01:00
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""count"": 5}}, {{""url"": ""file://{dest2}"", ""count"": ""invalid""}}]}}"
2026-01-08 20:33:32 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
2026-01-28 09:12:27 +01:00
// Deserializing the json should fail for the invalid count, resulting in no destinations being configured
Assert . AreEqual ( 0 , destinations . Count );
2026-01-08 20:33:32 +01:00
}
2026-01-28 09:54:32 +01:00
[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}\"}}]}}"
};
module . Configure ( options );
var syncOnWarnings = module . GetType (). GetField ( "m_syncOnWarnings" , BINDING_FLAGS ). GetValue ( module );
Assert . IsFalse (( bool ) syncOnWarnings );
}
2026-01-13 06:48:54 +01:00
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Scheduled_Initial ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""interval"", ""interval"": ""1h""}}]}}" ,
2026-01-13 06:48:54 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
// Create Operation table with no prior syncs
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
EnsureOperationTableCreated ( cmd );
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
Assert . IsTrue ( shouldTrigger );
}
2026-01-08 20:33:32 +01:00
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Scheduled_NotDue ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""interval"", ""interval"": ""1h""}}]}}" ,
2026-01-08 20:33:32 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
// Create Operation table and insert recent sync
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
cmd . CommandText = @"
INSERT INTO ""Operation"" (
""Description"",
""Timestamp""
) VALUES (
'Rsync 0',
@ts
)" ;
cmd . AddNamedParameter ( "@ts" , Library . Utility . Utility . NormalizeDateTimeToEpochSeconds ( DateTime . UtcNow ));
2026-01-08 20:33:32 +01:00
cmd . ExecuteNonQuery ();
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
2026-01-08 20:33:32 +01:00
Assert . IsFalse ( shouldTrigger );
}
[Test]
[Category("RemoteSync")]
2026-01-13 06:48:54 +01:00
public void TestShouldTriggerSync_Scheduled_Due ()
2026-01-08 20:33:32 +01:00
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""interval"", ""interval"": ""1h""}}]}}" ,
2026-01-08 20:33:32 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-13 06:48:54 +01:00
// Create Operation table and insert recent sync
2026-01-08 20:33:32 +01:00
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
cmd . CommandText = @"
INSERT INTO ""Operation"" (
""Description"",
""Timestamp""
) VALUES (
2026-01-13 06:48:54 +01:00
'Rsync 0',
2026-01-09 10:50:09 +01:00
@ts
)" ;
2026-01-13 06:48:54 +01:00
cmd . AddNamedParameter ( "@ts" , Library . Utility . Utility . NormalizeDateTimeToEpochSeconds ( DateTime . UtcNow - TimeSpan . FromHours ( 2 )));
2026-01-08 20:33:32 +01:00
cmd . ExecuteNonQuery ();
2026-01-13 06:48:54 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
Assert . IsTrue ( shouldTrigger );
}
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Counting_Initial ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""counting"", ""count"": 5}}]}}" ,
2026-01-13 06:48:54 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
// 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 = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
Assert . IsTrue ( shouldTrigger );
}
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Counting_NotReached ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""counting"", ""count"": 5}}]}}" ,
2026-01-13 06:48:54 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
// 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 ();
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
2026-01-08 20:33:32 +01:00
Assert . IsFalse ( shouldTrigger );
}
2026-01-13 06:48:54 +01:00
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Counting_Reached ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""counting"", ""count"": 2}}]}}" ,
2026-01-13 06:48:54 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
// 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 = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
Assert . IsTrue ( shouldTrigger );
}
2026-01-08 20:33:32 +01:00
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_MissingDbPath_ReturnsFalse ()
{
2026-01-09 10:57:54 +01:00
// 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 module doesn't trigger a sync,
// as the state is likely errornous.
2026-01-08 20:33:32 +01:00
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""inline""}}]}}"
2026-01-08 20:33:32 +01:00
// No dbpath
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 0 , destinations [ 0 ]]);
Assert . IsTrue ( shouldTrigger ); // Inline always returns true
2026-01-08 20:33:32 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_NonBackupOperation_SkipsSync ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-08 20:33:32 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:33:32 +01:00
module . OnStart ( "Restore" , ref remoteurl , ref localpath ); // Non-backup operation
var result = new TestBasicResults ( ParsedResultType . Success );
module . OnFinish ( result , null );
// Check if sync was not recorded
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Rsync 0'
" ;
2026-01-08 20:33:32 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 0 , count );
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_ExceptionDuringBackup_SkipsSync ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-08 20:33:32 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:33:32 +01:00
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
var result = new TestBasicResults ( ParsedResultType . Success );
var exception = new Exception ( "Test exception" );
module . OnFinish ( result , exception );
// Check if sync was not recorded
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Rsync 0'
" ;
2026-01-08 20:33:32 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 0 , count );
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_FatalResult_SkipsSync ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-08 20:33:32 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:33:32 +01:00
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
var result = new TestBasicResults ( ParsedResultType . Fatal );
module . OnFinish ( result , null );
// Check if sync was not recorded
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Rsync 0'
" ;
2026-01-08 20:33:32 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 0 , count );
}
[Test]
[Category("RemoteSync")]
public void TestRecordSyncOperation_MissingDbPath_NoCrash ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}"
2026-01-08 20:33:32 +01:00
// No dbpath
};
module . Configure ( options );
// Should not throw exception
2026-01-09 10:50:09 +01:00
Assert . DoesNotThrow (() => module . GetType (). GetMethod ( "RecordSyncOperation" , BINDING_FLAGS ). Invoke ( module , [ 0 ]));
2026-01-08 20:33:32 +01:00
}
[Test]
[Category("RemoteSync")]
2026-01-12 14:22:10 +01:00
public void TestConfigure_DefaultMode ()
2026-01-08 20:33:32 +01:00
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}, {{""url"": ""file://{dest2}"", ""mode"": ""interval""}}]}}"
2026-01-08 20:33:32 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
2026-01-08 20:33:32 +01:00
2026-01-12 14:22:10 +01:00
Assert . AreEqual ( 2 , destinations . Count );
Assert . AreEqual ( RemoteSyncTriggerMode . Inline , destinations [ 0 ]. Mode ); // Default
2026-01-28 05:38:59 +01:00
Assert . AreEqual ( RemoteSyncTriggerMode . Interval , destinations [ 1 ]. Mode );
2026-01-08 20:33:32 +01:00
}
2026-01-08 20:50:08 +01:00
[Test]
[Category("RemoteSync")]
public void TestModuleProperties ()
{
Assert . AreEqual ( "remotesync" , module . Key );
Assert . IsNotNull ( module . DisplayName );
Assert . IsNotEmpty ( module . DisplayName );
Assert . IsNotNull ( module . Description );
Assert . IsNotEmpty ( module . Description );
Assert . IsTrue ( module . LoadAsDefault );
2026-01-09 10:57:54 +01:00
// Check the number of supported commands. Count should be updated
// if commands are added or removed.
2026-01-08 20:50:08 +01:00
var supportedCommands = module . SupportedCommands ;
Assert . IsNotNull ( supportedCommands );
2026-01-12 14:22:10 +01:00
Assert . AreEqual ( 1 , supportedCommands . Count );
2026-01-08 20:50:08 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_WithEmptyDestinations_SkipsSync ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": ""file://{dest1}""}}, {{""url"": """"}}, {{""url"": ""file://{dest2}""}}]}}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string source = Path . Combine ( TARGETFOLDER , "sourcefolder" ); ;
string remoteurl = $"file://{source}/" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:50:08 +01:00
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
// Create Operation table
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
2026-01-08 20:50:08 +01:00
var result = new TestBasicResults ( ParsedResultType . Success );
module . OnFinish ( result , null );
// Check that two syncs were recorded (skipping the empty one)
2026-01-09 10:50:09 +01:00
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description""
LIKE 'Rsync %'
" ;
2026-01-08 20:50:08 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 2 , count ); // Two valid destinations, empty skipped
}
[Test]
[Category("RemoteSync")]
2026-01-12 14:22:10 +01:00
public void TestShouldTriggerSync_OutOfRangeIndex_ReturnsFalse ()
2026-01-08 20:50:08 +01:00
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""interval""}}]}}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
// Create Operation table
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
EnsureOperationTableCreated ( cmd );
2026-01-08 20:50:08 +01:00
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
var shouldTrigger = ( bool ) module . GetType (). GetMethod ( "ShouldTriggerSync" , BINDING_FLAGS ). Invoke ( module , [ 1 , destinations [ 0 ]]); // Index 1, but only 1 destination
Assert . IsFalse ( shouldTrigger ); // Out of range
2026-01-08 20:50:08 +01:00
}
2026-01-12 14:22:10 +01:00
2026-01-08 20:50:08 +01:00
[Test]
[Category("RemoteSync")]
public void TestOnStart_SourceHandling_DoesNotOverwriteExisting ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}"
2026-01-08 20:50:08 +01:00
};
module . Configure ( options );
// Set m_source manually
2026-01-28 09:12:27 +01:00
module . GetType (). GetField ( "m_source" , BINDING_FLAGS ). SetValue ( module , $"file://{source}" );
2026-01-08 20:50:08 +01:00
2026-01-28 09:12:27 +01:00
string remoteurl = "file://newsource" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:50:08 +01:00
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
2026-01-28 09:12:27 +01:00
var stored_source = module . GetType (). GetField ( "m_source" , BINDING_FLAGS ). GetValue ( module ) as string ;
Assert . AreEqual ( $"file://{source}" , stored_source ); // Should not overwrite
2026-01-08 20:50:08 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_WithMultipleDestinations_SelectiveSync ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": ""file://{dest1}"", ""mode"": ""inline""}}, {{""url"": ""file://{dest2}"", ""mode"": ""interval"", ""interval"": ""1h""}}]}}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:50:08 +01:00
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
// Create Operation table and insert recent sync for dest2
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
cmd . CommandText = @"
INSERT INTO ""Operation"" (
""Description"",
""Timestamp""
) VALUES (
'Rsync 1',
@ts
)" ;
cmd . AddNamedParameter ( "@ts" , Library . Utility . Utility . NormalizeDateTimeToEpochSeconds ( DateTime . UtcNow ));
2026-01-08 20:50:08 +01:00
cmd . ExecuteNonQuery ();
var result = new TestBasicResults ( ParsedResultType . Success );
module . OnFinish ( result , null );
2026-01-28 05:38:59 +01:00
// Check that one additional sync was recorded (dest1 inline, dest2 interval not due)
2026-01-09 10:50:09 +01:00
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" LIKE 'Rsync %'
" ;
2026-01-08 20:50:08 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 2 , count ); // Initial 1 + 1 new = 2
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_WithWarningResult_TriggersSync ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:50:08 +01:00
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
// Create Operation table
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
2026-01-08 20:50:08 +01:00
var result = new TestBasicResults ( ParsedResultType . Warning );
module . OnFinish ( result , null );
// Check that sync was recorded
2026-01-09 10:50:09 +01:00
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Rsync 0'
" ;
2026-01-08 20:50:08 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 1 , count );
}
2026-01-09 12:10:42 +01:00
[Test]
[Category("RemoteSync")]
public void TestOnFinish_WithWarningResult_SkipsSync_WhenDisabled ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""sync-on-warnings"": false, ""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-09 12:10:42 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 12:10:42 +01:00
string [] localpath = [];
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
// Create Operation table
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
EnsureOperationTableCreated ( cmd );
var result = new TestBasicResults ( ParsedResultType . Warning );
module . OnFinish ( result , null );
// Check that sync was not recorded
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Rsync 0'
" ;
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 0 , count );
}
2026-01-08 20:50:08 +01:00
[Test]
[Category("RemoteSync")]
2026-01-12 14:22:10 +01:00
public void TestConfigure_WithWhitespaceInDestinations_PreservesWhitespace ()
2026-01-08 20:50:08 +01:00
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = @ $"{{""destinations"": [{{""url"": "" file://{dest1} ""}}, {{""url"": "" file://{dest2} ""}}]}}"
2026-01-08 20:50:08 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
2026-01-08 20:50:08 +01:00
Assert . AreEqual ( 2 , destinations . Count );
2026-01-28 12:09:10 +01:00
// 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 );
2026-01-08 20:50:08 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_WithEmptyDestinationsInList_Skips ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}, {{}}, {{""url"": ""file://{dest2}""}}]}}"
2026-01-08 20:50:08 +01:00
};
module . Configure ( options );
2026-01-12 14:22:10 +01:00
var destinations = module . GetType (). GetField ( "m_destinations" , BINDING_FLAGS ). GetValue ( module ) as List < RemoteSyncDestinationConfig >;
Assert . AreEqual ( 3 , destinations . Count ); // All are included, empty ones skipped in OnFinish
2026-01-28 12:09:10 +01:00
// Convert the escaped backslashes to single backslashes for comparison on Windows
Assert . AreEqual ( $"file://{dest1.Replace(" \\\\ ", " \\ ")}" , destinations [ 0 ]. Config . Dst );
2026-01-12 14:22:10 +01:00
Assert . AreEqual ( "" , destinations [ 1 ]. Config . Dst );
2026-01-28 12:09:10 +01:00
Assert . AreEqual ( $"file://{dest2.Replace(" \\\\ ", " \\ ")}" , destinations [ 2 ]. Config . Dst );
2026-01-08 20:50:08 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestRecordSyncOperation_WithDifferentIndex ()
{
2026-01-09 10:56:55 +01:00
// Incorrect index should not record, but throw a warning.
2026-01-08 20:50:08 +01:00
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
// Create Operation table
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
2026-01-08 20:50:08 +01:00
2026-01-09 10:50:09 +01:00
module . GetType (). GetMethod ( "RecordSyncOperation" , BINDING_FLAGS ). Invoke ( module , [ 2 ]);
2026-01-08 20:50:08 +01:00
2026-01-09 10:50:09 +01:00
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Rsync 2'
" ;
2026-01-08 20:50:08 +01:00
var count = ( long ) cmd . ExecuteScalar ();
2026-01-09 10:56:55 +01:00
Assert . AreEqual ( 0 , count );
2026-01-08 20:50:08 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestRecordSyncOperation_WithMissingTable_ThrowsException ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
// Do not create Operation table
Assert . Throws < System . Reflection . TargetInvocationException >(() =>
2026-01-09 10:50:09 +01:00
module . GetType (). GetMethod ( "RecordSyncOperation" , BINDING_FLAGS ). Invoke ( module , [ 0 ]));
2026-01-08 20:50:08 +01:00
}
2026-01-12 14:22:10 +01:00
[Test]
[Category("RemoteSync")]
public void TestConfigure_InvalidJson_DoesNotActivate ()
{
var options = new Dictionary < string , string >
{
["remote-sync-json-config"] = @"invalid json"
};
module . Configure ( options );
var enabled = module . GetType (). GetField ( "m_enabled" , BINDING_FLAGS ). GetValue ( module );
Assert . IsFalse (( bool ) enabled );
}
2026-01-08 20:50:08 +01:00
[Test]
[Category("RemoteSync")]
public void TestConfigure_WithNoDestinations_DoesNotActivate ()
{
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
// No remote-sync-json-config
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-09 10:50:09 +01:00
var enabled = module . GetType (). GetField ( "m_enabled" , BINDING_FLAGS ). GetValue ( module );
2026-01-08 20:50:08 +01:00
Assert . IsFalse (( bool ) enabled );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:50:08 +01:00
module . OnStart ( "Backup" , ref remoteurl , ref localpath );
// Create Operation table
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
2026-01-08 20:50:08 +01:00
var result = new TestBasicResults ( ParsedResultType . Success );
module . OnFinish ( result , null );
// Check that no sync was recorded
2026-01-09 10:50:09 +01:00
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description""
LIKE 'Rsync %'
" ;
2026-01-08 20:50:08 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 0 , count );
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_ForNonBackupOperations_SkipsSync ()
{
var options = new Dictionary < string , string >
{
2026-01-28 09:12:27 +01:00
["remote-sync-json-config"] = $@"{{""destinations"": [{{""url"": ""file://{dest1}""}}]}}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
2026-01-28 09:12:27 +01:00
string remoteurl = $"file://{source}" ;
2026-01-09 10:50:09 +01:00
string [] localpath = [];
2026-01-08 20:50:08 +01:00
module . OnStart ( "List" , ref remoteurl , ref localpath ); // Non-backup operation
var result = new TestBasicResults ( ParsedResultType . Success );
module . OnFinish ( result , null );
// Check if sync was not recorded
using var db = SQLiteLoader . LoadConnection ( DBFILE );
using var cmd = db . CreateCommand ();
2026-01-09 10:50:09 +01:00
EnsureOperationTableCreated ( cmd );
cmd . CommandText = @"
SELECT COUNT(*)
FROM ""Operation""
WHERE ""Description"" = 'Rsync 0'
" ;
2026-01-08 20:50:08 +01:00
var count = ( long ) cmd . ExecuteScalar ();
Assert . AreEqual ( 0 , count );
}
2026-01-13 08:19:24 +01:00
[Test]
[Category("RemoteSync")]
public void TestMultipleBackups_WithCountingDestinations ()
{
// 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" );
2026-01-15 16:30:20 +01:00
// 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 );
2026-01-13 08:19:24 +01:00
var options = TestOptions ;
options [ "remote-sync-json-config" ] = $@"
{{
""destinations"": [
{{
2026-01-14 15:41:01 +01:00
""url"": ""file://{syncDest1.Replace(" \\ ", " \\\\ ")}""
2026-01-13 08:19:24 +01:00
}},
{{
2026-01-14 15:41:01 +01:00
"" url "" : "" file : //{syncDest2.Replace("\\", "\\\\")}"",
2026-01-13 08:19:24 +01:00
"" mode "" : "" counting "" ,
"" count "" : 2
}},
{{
2026-01-14 15:41:01 +01:00
"" url "" : "" file : //{syncDest3.Replace("\\", "\\\\")}"",
2026-01-13 08:19:24 +01:00
"" mode "" : "" counting "" ,
"" count "" : 3
}}
]
}} ";
options [ "dbpath" ] = DBFILE ;
// First backup
void Backup ()
{
2026-01-19 09:29:07 +01:00
Task . Delay ( 1000 ). Wait (); // Ensure timestamp difference between backups
2026-01-13 08:19:24 +01:00
using var console = new CommandLine . ConsoleOutput ( Console . Out , options );
using var controller = new Controller ( "file://" + TARGETFOLDER , options , console );
var result = controller . Backup ([ DATAFOLDER ]);
Assert . AreEqual ( ParsedResultType . Success , result . ParsedResult );
}
Backup ();
// 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 ( int 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 )
{
bool matches = true ;
for ( int 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
void AssertRestoreWorks ( 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 = controller . Restore ([ "*" ]);
}
DestinationsAreEqual ( true , [.. restoreFolders . Select ( f => System . IO . Path . Combine ( f , "data" ))]);
}
AssertRestoreWorks ([ TARGETFOLDER , syncDest1 , syncDest2 , syncDest3 ]);
// Add another file and backup
NewRandomFile ( testDataFolder , "file7.txt" );
Backup ();
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
AssertRestoreWorks ([ TARGETFOLDER , syncDest1 ]);
// Add another file and backup
NewRandomFile ( sub1 , "file8.txt" );
Backup ();
DestinationsAreEqual ( true , TARGETFOLDER , syncDest1 , syncDest2 );
Assert . IsFalse ( DestinationsAreEqual ( false , TARGETFOLDER , syncDest3 )); // syncDest3 should be out of date
AssertRestoreWorks ([ TARGETFOLDER , syncDest1 , syncDest2 ]);
// Add another file and backup
NewRandomFile ( sub2 , "file9.txt" );
Backup ();
DestinationsAreEqual ( true , TARGETFOLDER , syncDest1 , syncDest3 );
Assert . IsFalse ( DestinationsAreEqual ( false , TARGETFOLDER , syncDest2 )); // syncDest2 should be out of date
AssertRestoreWorks ([ TARGETFOLDER , syncDest1 , syncDest3 ]);
}
2026-01-08 20:27:40 +01:00
}
/// <summary>
/// Simple implementation of IBasicResults for testing.
/// </summary>
public class TestBasicResults : IBasicResults
{
public TestBasicResults ( ParsedResultType parsedResult )
{
ParsedResult = parsedResult ;
}
public DateTime BeginTime => DateTime . UtcNow ;
public DateTime EndTime => DateTime . UtcNow ;
public TimeSpan Duration => TimeSpan . Zero ;
2026-01-09 10:50:09 +01:00
public IEnumerable < string > Errors => [];
public IEnumerable < string > Warnings => [];
public IEnumerable < string > Messages => [];
2026-01-08 20:27:40 +01:00
public ParsedResultType ParsedResult { get ; }
public bool Interrupted => false ;
}
2026-01-08 20:50:08 +01:00
}