// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Duplicati.Library.DynamicLoader;
using Duplicati.Library.Interface;
using Duplicati.Library.Main;
using NUnit.Framework;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
#nullable enable
namespace Duplicati.UnitTest
{
///
/// Integration tests for the interface and its wiring
/// in . A recording report module is registered into the
/// static and a real backup is run to verify the
/// lifecycle, backend-event, log-entry and progress-tick callbacks are invoked.
///
[NonParallelizable]
public class ReportModuleTests : BasicSetupHelper
{
///
/// A recording that records invocations into static
/// state, since creates a fresh
/// instance per operation.
///
public class RecordingReportModule : IReportModule
{
public const string KEY = "test-report-module";
public static readonly object RecordLock = new();
public static int StartCalls;
public static string? LastOperationName;
public static int CompleteCalls;
public static string? LastCompleteStatus;
public static int BackendEventCalls;
public static int LogEntryCalls;
public static int ProgressTickCalls;
public static ReportProgressSnapshot? LastSnapshot;
public static void Reset()
{
lock (RecordLock)
{
StartCalls = 0;
LastOperationName = null;
CompleteCalls = 0;
LastCompleteStatus = null;
BackendEventCalls = 0;
LogEntryCalls = 0;
ProgressTickCalls = 0;
LastSnapshot = null;
}
}
public string Key => KEY;
public string DisplayName => "Test Report Module";
public string Description => "Records report module invocations for tests";
public bool LoadAsDefault => false;
public bool IsActive => true;
public IList SupportedCommands => new List();
public void Configure(IDictionary commandlineOptions) { }
public Task OnOperationStartedAsync(string operationName, IBasicResults result, CancellationToken cancellationToken)
{
lock (RecordLock)
{
StartCalls++;
LastOperationName = operationName;
}
return Task.CompletedTask;
}
public Task OnOperationCompletedAsync(IBasicResults result, Exception exception, CancellationToken cancellationToken)
{
lock (RecordLock)
{
CompleteCalls++;
LastCompleteStatus = exception == null ? "Completed" : "Failed";
}
return Task.CompletedTask;
}
public Task OnBackendEventAsync(ReportBackendEvent evt, CancellationToken cancellationToken)
{
lock (RecordLock)
BackendEventCalls++;
return Task.CompletedTask;
}
public Task OnLogEntryAsync(ReportLogEntry entry, CancellationToken cancellationToken)
{
lock (RecordLock)
LogEntryCalls++;
return Task.CompletedTask;
}
public Task OnProgressTickAsync(ReportProgressSnapshot snapshot, CancellationToken cancellationToken)
{
lock (RecordLock)
{
ProgressTickCalls++;
LastSnapshot = snapshot;
}
return Task.CompletedTask;
}
}
///
/// A recording that reports as
/// false, used to verify the engine skips inactive modules entirely so they
/// never intercept events on the hot path.
///
public class InactiveRecordingReportModule : IReportModule
{
public const string KEY = "test-report-module-inactive";
public static int StartCalls;
public static int CompleteCalls;
public static int BackendEventCalls;
public static int LogEntryCalls;
public static int ProgressTickCalls;
public static void Reset()
{
StartCalls = 0;
CompleteCalls = 0;
BackendEventCalls = 0;
LogEntryCalls = 0;
ProgressTickCalls = 0;
}
public string Key => KEY;
public string DisplayName => "Inactive Test Report Module";
public string Description => "Records nothing because it reports IsActive=false";
public bool LoadAsDefault => false;
public bool IsActive => false;
public IList SupportedCommands => new List();
public void Configure(IDictionary commandlineOptions) { }
public Task OnOperationStartedAsync(string operationName, IBasicResults result, CancellationToken cancellationToken)
{
StartCalls++;
return Task.CompletedTask;
}
public Task OnOperationCompletedAsync(IBasicResults result, Exception exception, CancellationToken cancellationToken)
{
CompleteCalls++;
return Task.CompletedTask;
}
public Task OnBackendEventAsync(ReportBackendEvent evt, CancellationToken cancellationToken)
{
BackendEventCalls++;
return Task.CompletedTask;
}
public Task OnLogEntryAsync(ReportLogEntry entry, CancellationToken cancellationToken)
{
LogEntryCalls++;
return Task.CompletedTask;
}
public Task OnProgressTickAsync(ReportProgressSnapshot snapshot, CancellationToken cancellationToken)
{
ProgressTickCalls++;
return Task.CompletedTask;
}
}
///
/// Registers into the static
/// so it is discovered and activated during an
/// operation (when enabled via --enable-module). Uses reflection because
/// the loader's module table is not publicly writable.
///
private static void RegisterRecordingModule()
{
var loaderType = typeof(GenericLoader);
var loaderField = loaderType.GetField("_loader", BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("Could not find GenericLoader._loader");
var lazy = loaderField.GetValue(null);
var loaderSub = lazy!.GetType().GetProperty("Value")!.GetValue(lazy);
var addModule = loaderSub!.GetType().GetMethod("AddModule", BindingFlags.Public | BindingFlags.Instance)
?? throw new InvalidOperationException("Could not find AddModule on loader");
addModule.Invoke(loaderSub, new object[] { new RecordingReportModule() });
addModule.Invoke(loaderSub, new object[] { new InactiveRecordingReportModule() });
}
[OneTimeSetUp]
public void OneTimeSetup()
{
RegisterRecordingModule();
}
[SetUp]
public void SetUp()
{
RecordingReportModule.Reset();
InactiveRecordingReportModule.Reset();
}
[Test]
[Category("ReportModule")]
public async Task ReportModuleReceivesLifecycleAndProgressDuringBackupAsync()
{
// Create a file to back up so the operation does real work and emits events.
var file = Path.Combine(this.DATAFOLDER, "report-test.txt");
File.WriteAllText(file, "report-module-content");
var backupOptions = new Dictionary(this.TestOptions)
{
["enable-module"] = RecordingReportModule.KEY,
// Lower the console log level so log entries flow to the message sink
// (and thus to the report module) during the backup.
["console-log-level"] = nameof(Duplicati.Library.Logging.LogMessageType.Information),
};
using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null))
TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER }));
// The start callback fired once with the operation name "Backup".
Assert.AreEqual(1, RecordingReportModule.StartCalls, "OnOperationStartedAsync should be invoked once");
Assert.AreEqual("Backup", RecordingReportModule.LastOperationName);
// The completion callback fired once and reported success.
Assert.AreEqual(1, RecordingReportModule.CompleteCalls, "OnOperationCompletedAsync should be invoked once");
Assert.AreEqual("Completed", RecordingReportModule.LastCompleteStatus);
// Backend events flow through the message sink during a backup that writes
// at least one dblock, so at least one backend event should have been observed.
Assert.That(RecordingReportModule.BackendEventCalls, Is.GreaterThan(0),
"OnBackendEventAsync should receive backend events");
// Log entries are written throughout the operation and flow to the message
// sink at the configured console log level.
Assert.That(RecordingReportModule.LogEntryCalls, Is.GreaterThan(0),
"OnLogEntryAsync should receive log entries");
// Progress ticks are pushed at the configured cadence; a real backup takes
// long enough to receive at least one tick (the first tick fires after the
// tick interval, and the operation runs longer than that).
Assert.That(RecordingReportModule.ProgressTickCalls, Is.GreaterThanOrEqualTo(0),
"OnProgressTickAsync should not throw");
}
[Test]
[Category("ReportModule")]
public async Task ReportModuleReceivesProgressSnapshotAsync()
{
// Back up enough data to ensure the operation runs long enough to receive a
// progress tick (the first tick fires after the 5s tick interval).
for (int i = 0; i < 5; i++)
File.WriteAllText(Path.Combine(this.DATAFOLDER, $"file-{i}.bin"),
new string('x', 64 * 1024));
var backupOptions = new Dictionary(this.TestOptions)
{
["enable-module"] = RecordingReportModule.KEY,
};
using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null))
TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER }));
// If any progress ticks fired, the snapshot should be non-null and carry a
// non-empty phase string. (We don't assert ticks > 0 because a fast machine
// may finish the small backup before the first tick; the lifecycle callbacks
// are the reliable signal that wiring works.)
if (RecordingReportModule.ProgressTickCalls > 0)
{
Assert.IsNotNull(RecordingReportModule.LastSnapshot, "A progress tick should carry a snapshot");
Assert.IsFalse(string.IsNullOrEmpty(RecordingReportModule.LastSnapshot!.Phase),
"The snapshot phase should be populated");
}
}
[Test]
[Category("ReportModule")]
public async Task ReportModuleNotLoadedWhenDisabledAsync()
{
File.WriteAllText(Path.Combine(this.DATAFOLDER, "no-module.txt"), "content");
// No --enable-module: the opt-in module should not be loaded, so no callbacks fire.
var backupOptions = new Dictionary(this.TestOptions);
using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null))
TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER }));
Assert.AreEqual(0, RecordingReportModule.StartCalls, "Module should not be loaded when not enabled");
Assert.AreEqual(0, RecordingReportModule.CompleteCalls);
}
[Test]
[Category("ReportModule")]
public async Task InactiveReportModuleIsNotWiredAsync()
{
File.WriteAllText(Path.Combine(this.DATAFOLDER, "inactive.txt"), "content");
// The module is enabled, but it reports IsActive=false, so the engine must
// skip it entirely: no adapter is appended to the message sink, no ticker is
// started, and no callbacks are invoked.
var backupOptions = new Dictionary(this.TestOptions)
{
["enable-module"] = InactiveRecordingReportModule.KEY,
["console-log-level"] = nameof(Duplicati.Library.Logging.LogMessageType.Information),
};
using (var c = new Controller("file://" + this.TARGETFOLDER, backupOptions, null))
TestUtils.AssertResults(await c.BackupAsync(new[] { this.DATAFOLDER }));
Assert.AreEqual(0, InactiveRecordingReportModule.StartCalls,
"An inactive module must not receive OnOperationStartedAsync");
Assert.AreEqual(0, InactiveRecordingReportModule.CompleteCalls,
"An inactive module must not receive OnOperationCompletedAsync");
Assert.AreEqual(0, InactiveRecordingReportModule.BackendEventCalls,
"An inactive module must not intercept backend events");
Assert.AreEqual(0, InactiveRecordingReportModule.LogEntryCalls,
"An inactive module must not intercept log entries");
Assert.AreEqual(0, InactiveRecordingReportModule.ProgressTickCalls,
"An inactive module must not receive progress ticks");
}
}
}