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 ;
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 ;
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}"
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-12 14:22:10 +01:00
Assert . AreEqual ( "file:///test/dest" , destinations [ 0 ]. Config . Dst );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_MultipleDestinations ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest1""}, {""url"": ""file:///test/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-12 14:22:10 +01:00
Assert . AreEqual ( "file:///test/dest1" , destinations [ 0 ]. Config . Dst );
Assert . AreEqual ( "file:///test/dest2" , destinations [ 1 ]. Config . Dst );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_Modes ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest1"", ""mode"": ""inline""}, {""url"": ""file:///test/dest2"", ""mode"": ""scheduled""}]}"
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 );
Assert . AreEqual ( RemoteSyncTriggerMode . Scheduled , destinations [ 1 ]. Mode );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_Schedules ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest1"", ""schedule"": ""1:00:00""}, {""url"": ""file:///test/dest2"", ""schedule"": ""2:00:00""}]}"
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 ( TimeSpan . FromHours ( 1 ), destinations [ 0 ]. Schedule );
Assert . AreEqual ( TimeSpan . FromHours ( 2 ), destinations [ 1 ]. Schedule );
2026-01-08 20:27:40 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_Counts ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest1"", ""count"": 5}, {""url"": ""file:///test/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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}"
2026-01-08 20:27:40 +01:00
};
module . Configure ( options );
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-09 10:50:09 +01:00
var source = module . GetType (). GetField ( "m_source" , BINDING_FLAGS ). GetValue ( module ) as string ;
2026-01-08 20:27:40 +01:00
Assert . AreEqual ( "file:///source" , source );
}
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Inline ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest"", ""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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest"", ""mode"": ""scheduled"", ""schedule"": ""1:00:00""}]}" ,
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest"", ""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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}" ,
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-12 14:22:10 +01:00
options [ "remote-sync-json-config" ] = @"{""destinations"": [{""url"": ""file://" + System . IO . Path . Combine ( BASEFOLDER , "syncdest" ) + @"""}]}" ;
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 );
using var controller = new Controller ( "file://" + TARGETFOLDER , options , console );
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}" ,
2026-01-08 20:27:40 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest1"", ""mode"": ""inline""}, {""url"": ""file:///test/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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest1"", ""schedule"": ""1:00:00""}, {""url"": ""file:///test/dest2"", ""schedule"": ""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 );
Assert . AreEqual ( TimeSpan . FromHours ( 1 ), destinations [ 0 ]. Schedule );
Assert . AreEqual ( null , destinations [ 1 ]. Schedule ); // Invalid defaults to null
2026-01-08 20:33:32 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_InvalidCount_DefaultsToZero ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest1"", ""count"": 5}, {""url"": ""file:///test/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 >;
Assert . AreEqual ( 2 , destinations . Count );
Assert . AreEqual ( 5 , destinations [ 0 ]. Count );
Assert . AreEqual ( 0 , destinations [ 1 ]. Count ); // Invalid defaults to 0
2026-01-08 20:33:32 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestShouldTriggerSync_Scheduled_NotDue ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest"", ""mode"": ""scheduled"", ""schedule"": ""1:00:00""}]}" ,
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")]
public void TestShouldTriggerSync_Counting_NotReached ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest"", ""mode"": ""counting"", ""count"": 5}]}" ,
2026-01-08 20:33:32 +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 = 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:33:32 +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:33:32 +01:00
Assert . IsFalse ( shouldTrigger );
}
[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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest"", ""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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}" ,
2026-01-08 20:33:32 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}" ,
2026-01-08 20:33:32 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}" ,
2026-01-08 20:33:32 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}"
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest1""}, {""url"": ""file:///test/dest2"", ""mode"": ""scheduled""}]}"
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
Assert . AreEqual ( RemoteSyncTriggerMode . Scheduled , 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 ()
{
var module = new RemoteSynchronizationModule ();
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///valid""}, {""url"": """"}, {""url"": ""file:///another""}]}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest"", ""mode"": ""scheduled""}]}" ,
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}"
2026-01-08 20:50:08 +01:00
};
module . Configure ( options );
// Set m_source manually
2026-01-09 10:50:09 +01:00
module . GetType (). GetField ( "m_source" , BINDING_FLAGS ). SetValue ( module , "file:///existing" );
2026-01-08 20:50:08 +01:00
string remoteurl = "file:///new" ;
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-09 10:50:09 +01:00
var source = module . GetType (). GetField ( "m_source" , BINDING_FLAGS ). GetValue ( module ) as string ;
2026-01-08 20:50:08 +01:00
Assert . AreEqual ( "file:///existing" , source ); // Should not overwrite
}
[Test]
[Category("RemoteSync")]
public void TestOnFinish_WithMultipleDestinations_SelectiveSync ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///dest1"", ""mode"": ""inline""}, {""url"": ""file:///dest2"", ""mode"": ""scheduled"", ""schedule"": ""1:00:00""}]}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
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 );
// Check that one additional sync was recorded (dest1 inline, dest2 scheduled 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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""sync-on-warnings"": false, ""destinations"": [{""url"": ""file:///test/dest""}]}" ,
2026-01-09 12:10:42 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
string remoteurl = "file:///source" ;
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +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-12 14:22:10 +01:00
Assert . AreEqual ( " file:///dest1 " , destinations [ 0 ]. Config . Dst ); // JSON preserves spaces
Assert . AreEqual ( " file:///dest2 " , destinations [ 1 ]. Config . Dst );
2026-01-08 20:50:08 +01:00
}
[Test]
[Category("RemoteSync")]
public void TestConfigure_WithEmptyDestinationsInList_Skips ()
{
var module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +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
Assert . AreEqual ( "file:///dest1" , destinations [ 0 ]. Config . Dst );
Assert . AreEqual ( "" , destinations [ 1 ]. Config . Dst );
Assert . AreEqual ( "file:///dest2" , 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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}" ,
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}" ,
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 module = new RemoteSynchronizationModule ();
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 module = new RemoteSynchronizationModule ();
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 );
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 module = new RemoteSynchronizationModule ();
var options = new Dictionary < string , string >
{
2026-01-12 14:22:10 +01:00
["remote-sync-json-config"] = @"{""destinations"": [{""url"": ""file:///test/dest""}]}" ,
2026-01-08 20:50:08 +01:00
["dbpath"] = DBFILE
};
module . Configure ( options );
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-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
}