Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
using NSubstitute;
using UniGetUI.Interface.Enums;
using UniGetUI.PackageEngine.Interfaces;
using UniGetUI.PackageEngine.PackageClasses;

namespace UniGetUI.PackageEngine.Tests;

/// <summary>
/// Tests for duplicate detection in update operations (Issue #4131).
/// These tests verify the logic used in AppOperationHelper.UpdateAll() and UpdateAllForManager()
/// to prevent multiple update operations for the same package from being queued simultaneously.
/// </summary>
public class TestDuplicateUpdateDetection
{
/// <summary>
/// Test that verifies Package.GetHash() returns the same value for identical packages.
/// This is the core mechanism used to detect duplicate operations in the queue.
///
/// Background: Issue #4131 - When unattended updates trigger repeatedly while UAC prompts
/// are pending, UpdateAll() was creating dozens of duplicate operations for the same package.
/// The fix uses GetHash() to check if an operation for a package already exists in the queue.
/// </summary>
[Fact]
public void GetHash_ShouldReturnSameValue_ForIdenticalPackages()
{
// Arrange - Create mock manager and source
// Note: We use the same instances to ensure consistent hashing
var mockManager = Substitute.For<IPackageManager>();
mockManager.Name.Returns("TestManager");

var mockSource = Substitute.For<IManagerSource>();
mockSource.Name.Returns("TestSource");

// Create two package instances with identical identity (same manager, source, and ID)
var package1 = new Package(
name: "TestPackage",
id: "test.package.id",
version: "1.0.0",
source: mockSource,
manager: mockManager
);

var package2 = new Package(
name: "TestPackage",
id: "test.package.id",
version: "1.0.0",
source: mockSource,
manager: mockManager
);

// Act - Get hash for both packages
long hash1 = package1.GetHash();
long hash2 = package2.GetHash();

// Assert - Hashes must be equal for the duplicate detection to work
Assert.Equal(hash1, hash2);
}

/// <summary>
/// Test that verifies Package.GetHash() returns different values for different packages.
/// This ensures the duplicate detection doesn't falsely match unrelated packages.
/// </summary>
[Fact]
public void GetHash_ShouldReturnDifferentValues_ForDifferentPackages()
{
// Arrange
var mockManager = Substitute.For<IPackageManager>();
mockManager.Name.Returns("TestManager");

var mockSource = Substitute.For<IManagerSource>();
mockSource.Name.Returns("TestSource");

// Create packages with different IDs
var package1 = new Package(
name: "TestPackage1",
id: "test.package.one",
version: "1.0.0",
source: mockSource,
manager: mockManager
);

var package2 = new Package(
name: "TestPackage2",
id: "test.package.two",
version: "1.0.0",
source: mockSource,
manager: mockManager
);

// Act
long hash1 = package1.GetHash();
long hash2 = package2.GetHash();

// Assert - Hashes must be different so we don't skip legitimate different packages
Assert.NotEqual(hash1, hash2);
}

/// <summary>
/// Test that verifies packages from different managers have different hashes.
/// The duplicate detection must consider the package manager as part of identity.
/// </summary>
[Fact]
public void GetHash_ShouldDiffer_ForSamePackageFromDifferentManagers()
{
// Arrange - Create two different managers
var mockManager1 = Substitute.For<IPackageManager>();
mockManager1.Name.Returns("WinGet");

var mockManager2 = Substitute.For<IPackageManager>();
mockManager2.Name.Returns("Chocolatey");

var mockSource = Substitute.For<IManagerSource>();
mockSource.Name.Returns("TestSource");

// Same package ID from different managers
var packageFromWinGet = new Package(
name: "TestPackage",
id: "test.package",
version: "1.0.0",
source: mockSource,
manager: mockManager1
);

var packageFromChoco = new Package(
name: "TestPackage",
id: "test.package",
version: "1.0.0",
source: mockSource,
manager: mockManager2
);

// Act
long hashWinGet = packageFromWinGet.GetHash();
long hashChoco = packageFromChoco.GetHash();

// Assert - Must be different because they're from different managers
Assert.NotEqual(hashWinGet, hashChoco);
}

/// <summary>
/// Test simulating the scenario where multiple UpdateAll() calls should detect duplicates.
/// This represents what happens when auto-updates trigger while operations are queued.
/// </summary>
[Fact]
public void SimulateDuplicateDetection_MultipleIdenticalPackages_ShouldHaveSameHash()
{
// Arrange - Simulate receiving same package multiple times from UpgradablePackagesLoader
var mockManager = Substitute.For<IPackageManager>();
mockManager.Name.Returns("WinGet");

var mockSource = Substitute.For<IManagerSource>();
mockSource.Name.Returns("winget");

// Simulate UpdateAll() being called 3 times, each time getting "same" package
var packagesFromFirstCall = new Package(
name: "Git",
id: "Git.Git",
version: "2.40.0",
source: mockSource,
manager: mockManager
);

var packagesFromSecondCall = new Package(
name: "Git",
id: "Git.Git",
version: "2.40.0",
source: mockSource,
manager: mockManager
);

var packagesFromThirdCall = new Package(
name: "Git",
id: "Git.Git",
version: "2.40.0",
source: mockSource,
manager: mockManager
);

// Act - Get hashes that would be used for duplicate detection
var hash1 = packagesFromFirstCall.GetHash();
var hash2 = packagesFromSecondCall.GetHash();
var hash3 = packagesFromThirdCall.GetHash();

// Assert - All three should have the same hash, allowing duplicate detection
Assert.Equal(hash1, hash2);
Assert.Equal(hash2, hash3);
Assert.Equal(hash1, hash3);

// In the actual implementation, the second and third calls would find
// an existing operation with matching hash and skip creating duplicates
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>
Expand All @@ -22,6 +23,9 @@

<ItemGroup>
<ProjectReference Include="..\UniGetUI.PackageEngine.Serializable\UniGetUI.PackageEngine.Serializable.csproj" />
<ProjectReference Include="..\UniGetUI.PackageEngine.PackageManagerClasses\UniGetUI.PackageEngine.Classes.csproj" />
<ProjectReference Include="..\UniGetUI.PAckageEngine.Interfaces\UniGetUI.PackageEngine.Interfaces.csproj" />
<ProjectReference Include="..\UniGetUI.Interface.Enums\UniGetUI.Interface.Enums.csproj" />
</ItemGroup>

</Project>
28 changes: 25 additions & 3 deletions src/UniGetUI/AppOperationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,17 +259,39 @@ public static void Update(IReadOnlyList<IPackage> packages, bool? elevated = nul
public static async Task UpdateAll()
{
foreach (IPackage package in UpgradablePackagesLoader.Instance.Packages)
{
if (package.Tag is not PackageTag.BeingProcessed and not PackageTag.OnQueue)
await Update(package);
{
// Check for duplicate operations already in the queue (prevents duplicates during unattended updates)
var isDuplicate = _operationList.Any(x => x.Operation is UpdatePackageOperation updateOp
&& updateOp.Package.GetHash() == package.GetHash()
&& (x.Operation.Status is OperationStatus.InQueue or OperationStatus.Running));

if (!isDuplicate)
await Update(package);
else
Logger.Info($"Update operation for package {package.Id} is already queued or running. Skipping duplicate in UpdateAll.");
}
}
}

public static async Task UpdateAllForManager(string managerName)
{
foreach (IPackage package in UpgradablePackagesLoader.Instance.Packages)
{
if (package.Tag is not PackageTag.OnQueue and not PackageTag.BeingProcessed
&& package.Manager.Name == managerName || package.Manager.DisplayName == managerName)
await Update(package);
&& (package.Manager.Name == managerName || package.Manager.DisplayName == managerName))
{
// Check for duplicate operations already in the queue (prevents duplicates during unattended updates)
var isDuplicate = _operationList.Any(x => x.Operation is UpdatePackageOperation updateOp
&& updateOp.Package.GetHash() == package.GetHash()
&& (x.Operation.Status is OperationStatus.InQueue or OperationStatus.Running));

if (!isDuplicate)
await Update(package);
else
Logger.Info($"Update operation for package {package.Id} is already queued or running. Skipping duplicate in UpdateAllForManager.");
}
}
}

Expand Down
Loading