File: BuildModelFactoryTests.cs
Web Access
Project: src\src\Microsoft.DotNet.Build.Tasks.Feed.Tests\Microsoft.DotNet.Build.Tasks.Feed.Tests.csproj (Microsoft.DotNet.Build.Tasks.Feed.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.IO;
using System.Xml.Linq;
using Azure.Storage.Blobs.Models;
using FluentAssertions;
using Microsoft.Arcade.Common;
using Microsoft.Arcade.Test.Common;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Microsoft.DotNet.Build.Tasks.Feed.Tests.TestDoubles;
using Microsoft.DotNet.VersionTools.Automation;
using Microsoft.DotNet.VersionTools.BuildManifest.Model;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
using static Microsoft.Build.Utilities.SDKManifest;
 
namespace Microsoft.DotNet.Build.Tasks.Feed.Tests
{
    public class BuildModelFactoryTests
    {
        #region Standard test values
 
        private const string _testAzdoRepoUri = "https://dnceng@dev.azure.com/dnceng/internal/_git/dotnet-buildtest";
        private const string _normalizedTestAzdoRepoUri = "https://dev.azure.com/dnceng/internal/_git/dotnet-buildtest";
        private const string _testRepoOrigin = "emsdk";
        private const string _testBuildBranch = "foobranch";
        private const string _testBuildCommit = "664996a16fa9228cfd7a55d767deb31f62a65f51";
        private const string _testAzdoBuildId = "89999999";
        private const string _testInitialLocation = "https://dnceng.visualstudio.com/project/_apis/build/builds/id/artifacts";
        private const string _normalizedTestInitialLocation = "https://dev.azure.com/dnceng/project/_apis/build/builds/id/artifacts";
        private static readonly string[] _defaultManifestBuildData = new string[]
        {
            $"InitialAssetsLocation={_testInitialLocation}",
            $"AzureDevOpsRepository={_testAzdoRepoUri}"
        };
 
        #endregion
 
        readonly TaskLoggingHelper _taskLoggingHelper;
        readonly MockBuildEngine _buildEngine;
        readonly StubTask _stubTask;
        readonly BuildModelFactory _buildModelFactory;
        readonly FileSystem _fileSystem;
 
        public BuildModelFactoryTests()
        {
            _buildEngine = new MockBuildEngine();
            _stubTask = new StubTask(_buildEngine);
            _taskLoggingHelper = new TaskLoggingHelper(_stubTask);
 
            ServiceProvider provider = new ServiceCollection()
                .AddSingleton<IBlobArtifactModelFactory, BlobArtifactModelFactory>()
                .AddSingleton<IPackageArtifactModelFactory, PackageArtifactModelFactory>()
                .AddSingleton<IPdbArtifactModelFactory, PdbArtifactModelFactory>()
                .AddSingleton<IPackageArchiveReaderFactory, PackageArchiveReaderFactory>()
                .AddSingleton<INupkgInfoFactory, NupkgInfoFactory>()
                .AddSingleton<IFileSystem, FileSystem>()
                .AddSingleton(typeof(BuildModelFactory))
                .AddSingleton(_taskLoggingHelper)
                .BuildServiceProvider();
            
            _buildModelFactory = ActivatorUtilities.CreateInstance<BuildModelFactory>(provider);
            _fileSystem = ActivatorUtilities.CreateInstance<FileSystem>(provider);
        }
 
        /// <summary>
        /// A model with no input artifacts is invalid
        /// </summary>
        [Fact]
        public void AttemptToCreateModelWithNoArtifactsFails()
        {
            Action act = () =>
                _buildModelFactory.CreateModel(artifacts: null,
                                               artifactVisibilitiesToInclude: ArtifactVisibility.All,
                                               buildId: _testAzdoBuildId,
                                               manifestBuildData: null,
                                               repoUri: _testAzdoRepoUri,
                                               repoBranch: _testBuildBranch,
                                               repoCommit: _testBuildCommit,
                                               repoOrigin: _testRepoOrigin,
                                               isStableBuild: false,
                                               publishingVersion: PublishingInfraVersion.Latest,
                                               isReleaseOnlyPackageVersion: true);
            act.Should().Throw<ArgumentNullException>();
        }
 
        /// <summary>
        /// Relatively unified test of manifest artifact parsing. Focuses on 3 things:
        /// - That symbol packages are correctly identified as blobs in the correct locations
        /// - Blobs and packages are split into appropriate categories
        /// - Artifact metadata is preserved, which includes the attributes
        /// 
        /// Because there is a ton of overlap between the individual tests of this functionality
        /// (they essentially all need to verify the same permutations), these tests are combined into
        /// one.
        /// </summary>
        [Fact]
        public void ManifestArtifactParsingTest()
        {
            var localPackagePath = TestInputs.GetFullPath(Path.Combine("Nupkgs", "test-package-a.1.0.0.nupkg"));
 
            const string bopSymbolsNupkg = "foo/bar/baz/bop.symbols.nupkg";
            string bobSymbolsExpectedId = $"assets/symbols/{Path.GetFileName(bopSymbolsNupkg)}";
            const string bopSnupkg = "foo/bar/baz/bop.symbols.nupkg";
            string bopSnupkgExpectedId = $"assets/symbols/{Path.GetFileName(bopSnupkg)}";
            const string zipArtifact = "foo/bar/baz/bing.zip";
            // New PDB artifact
            const string pdbArtifact = "foo/bar/baz/bing.pdb";
 
            var artifacts = new ITaskItem[]
            {
                new TaskItem(bopSymbolsNupkg, new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "NonShipping=true;Category=SMORKELER" },
                    { "RelativeBlobPath", bobSymbolsExpectedId},
                    { "ThisIsntArtifactMetadata", "YouGoofed!" },
                    { "Kind", "Blob" }
                }),
                new TaskItem(bopSnupkg, new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "NonShipping=false;Category=SNORPKEG;" },
                    { "RelativeBlobPath", bopSnupkgExpectedId},
                    { "Kind", "Blob" },
                }),
                // Include a package and a fake zip too
                // Note that the relative blob path is a "first class" attribute,
                // not parsed from ManifestArtifactData
                new TaskItem(zipArtifact, new Dictionary<string, string>
                {
                    { "RelativeBlobPath", zipArtifact },
                    { "ManifestArtifactData", "ARandomBitOfMAD=" },
                    { "Kind", "Blob" }
                }),
                new TaskItem(localPackagePath, new Dictionary<string, string>()
                {
                    // This isn't recognized or used for a nupkg
                    { "RelativeBlobPath", zipArtifact },
                    { "ManifestArtifactData", "ShouldWePushDaNorpKeg=YES" },
                    { "Kind", "Package" }
                }),
                // New PDB artifact with RelativePdbPath
                new TaskItem(pdbArtifact, new Dictionary<string, string>
                {
                    { "RelativePdbPath", pdbArtifact },
                    { "ManifestArtifactData", "NonShipping=false;Category=PDB" },
                    { "Kind", "PDB" }
                })
            };
 
            var model = _buildModelFactory.CreateModel(artifacts: artifacts,
                                                       artifactVisibilitiesToInclude: ArtifactVisibility.All,
                                                       buildId: _testAzdoBuildId,
                                                       manifestBuildData: _defaultManifestBuildData,
                                                       repoUri: _testAzdoRepoUri,
                                                       repoBranch: _testBuildBranch,
                                                       repoCommit: _testBuildCommit,
                                                       repoOrigin: _testRepoOrigin,
                                                       isStableBuild: false,
                                                       publishingVersion: PublishingInfraVersion.Latest,
                                                       isReleaseOnlyPackageVersion: true);
 
            model.Should().NotBeNull();
            _taskLoggingHelper.HasLoggedErrors.Should().BeFalse();
 
            // When Maestro sees a symbol package, it is supposed to re-do the symbol package path to
            // be assets/symbols/<file-name>
            model.Artifacts.Blobs.Should().SatisfyRespectively(
                blob =>
                {
                    blob.Id.Should().Be(bobSymbolsExpectedId);
                    blob.NonShipping.Should().BeTrue();
                    blob.Attributes.Should().Contain("NonShipping", "true");
                    blob.Attributes.Should().Contain("Category", "SMORKELER");
                    blob.Attributes.Should().Contain("Id", bobSymbolsExpectedId);
                    blob.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                },
                blob =>
                {
                    blob.Id.Should().Be(bopSnupkgExpectedId);
                    blob.NonShipping.Should().BeFalse();
                    blob.Attributes.Should().Contain("NonShipping", "false");
                    blob.Attributes.Should().Contain("Category", "SNORPKEG");
                    blob.Attributes.Should().Contain("Id", bopSnupkgExpectedId);
                    blob.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                },
                blob =>
                {
                    blob.Id.Should().Be(zipArtifact);
                    blob.NonShipping.Should().BeFalse();
                    blob.Attributes.Should().Contain("ARandomBitOfMAD", string.Empty);
                    blob.Attributes.Should().Contain("Id", zipArtifact);
                    blob.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                });
 
            model.Artifacts.Packages.Should().SatisfyRespectively(
                package =>
                {
                    package.Id.Should().Be("test-package-a");
                    package.Version.Should().Be("1.0.0");
                    package.NonShipping.Should().BeFalse();
                    package.Attributes.Should().Contain("ShouldWePushDaNorpKeg", "YES");
                    package.Attributes.Should().Contain("Id", "test-package-a");
                    package.Attributes.Should().Contain("Version", "1.0.0");
                    package.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                });
 
            // New verification for the PDB artifact
            model.Artifacts.Pdbs.Should().SatisfyRespectively(
                pdb =>
                {
                    pdb.Id.Should().Be(pdbArtifact);
                    pdb.NonShipping.Should().BeFalse();
                    pdb.Attributes.Should().Contain("NonShipping", "false");
                    pdb.Attributes.Should().Contain("Category", "PDB");
                    pdb.Attributes.Should().Contain("Id", pdbArtifact);
                    pdb.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                }
            );
 
            model.Identity.Attributes.Should().Contain("AzureDevOpsRepository", _normalizedTestAzdoRepoUri);
        }
 
        /// <summary>
        /// The artifact metadata is parsed as case-insensitive
        /// </summary>
        [Fact]
        public void ArtifactMetadataIsCaseInsensitive()
        {
            var localPackagePath = TestInputs.GetFullPath(Path.Combine("Nupkgs", "test-package-a.1.0.0.nupkg"));
 
            var artifacts = new ITaskItem[]
            {
                new TaskItem(localPackagePath, new Dictionary<string, string>()
                {
                    { "ManifestArtifactData", "nonshipping=true;Category=CASE" },
                    { "Kind", "Package" }
                })
            };
 
            var model = _buildModelFactory.CreateModel(artifacts: artifacts,
                                                       artifactVisibilitiesToInclude: ArtifactVisibility.All,
                                                       buildId: _testAzdoBuildId,
                                                       manifestBuildData: _defaultManifestBuildData,
                                                       repoUri: _testAzdoRepoUri,
                                                       repoBranch: _testBuildBranch,
                                                       repoCommit: _testBuildCommit,
                                                       repoOrigin: _testRepoOrigin,
                                                       isStableBuild: false,
                                                       publishingVersion: PublishingInfraVersion.Latest,
                                                       isReleaseOnlyPackageVersion: true);
 
            model.Should().NotBeNull();
            _taskLoggingHelper.HasLoggedErrors.Should().BeFalse();
 
            model.Artifacts.Blobs.Should().BeEmpty();
            model.Artifacts.Packages.Should().SatisfyRespectively(
                package =>
                {
                    package.Id.Should().Be("test-package-a");
                    package.Version.Should().Be("1.0.0");
                    // We used "nonshipping=true" in our artifact metadata
                    package.NonShipping.Should().BeTrue();
                    package.Attributes.Should().Contain("nonshipping", "true");
                    package.Attributes.Should().Contain("Category", "CASE");
                    package.Attributes.Should().Contain("Id", "test-package-a");
                    package.Attributes.Should().Contain("Version", "1.0.0");
                    package.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                });
        }
 
        
 
        /// <summary>
        /// We can't create a blob artifact model without a RelativeBlobPath
        /// </summary>
        [Fact]
        public void BlobsWithoutARelativeBlobPathIsInvalid()
        {
            const string zipArtifact = "foo/bar/baz/bing.zip";
 
            var artifacts = new ITaskItem[]
            {
                // Include a package and a fake zip too
                // Note that the relative blob path is a "first class" attribute,
                // not parsed from ManifestArtifactData
                new TaskItem(zipArtifact, new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "ARandomBitOfMAD=" },
                    { "Kind", "Blob" },
                }),
            };
 
            _buildModelFactory.CreateModel(artifacts: artifacts,
                                           artifactVisibilitiesToInclude: ArtifactVisibility.All,
                                           buildId: _testAzdoBuildId,
                                           manifestBuildData: _defaultManifestBuildData,
                                           repoUri: _testAzdoRepoUri,
                                           repoBranch: _testBuildBranch,
                                           repoCommit: _testBuildCommit,
                                           repoOrigin: _testRepoOrigin,
                                           isStableBuild: false,
                                           publishingVersion: PublishingInfraVersion.Latest,
                                           isReleaseOnlyPackageVersion: true);
 
            _taskLoggingHelper.HasLoggedErrors.Should().BeTrue();
            _buildEngine.BuildErrorEvents.Should().Contain(e => e.Message.Equals($"Missing 'RelativeBlobPath' property on blob {zipArtifact}"));
        }
 
        /// <summary>
        /// We can't create a PDB artifact model without a RelativePdbPath
        /// </summary>
        [Fact]
        public void PdbsWithoutARelativePdbPathIsInvalid()
        {
            const string pdbArtifact = "foo/bar/baz/bing.pdb";
 
            var artifacts = new ITaskItem[]
            {
                // Include a package and a fake pdb too
                // Note that the relative pdb path is a "first class" attribute,
                // not parsed from ManifestArtifactData
                new TaskItem(pdbArtifact, new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "ARandomBitOfMAD=" },
                    { "Kind", "PDB" },
                }),
            };
 
            _buildModelFactory.CreateModel(artifacts: artifacts,
                                           artifactVisibilitiesToInclude: ArtifactVisibility.All,
                                           buildId: _testAzdoBuildId,
                                           manifestBuildData: _defaultManifestBuildData,
                                           repoUri: _testAzdoRepoUri,
                                           repoBranch: _testBuildBranch,
                                           repoCommit: _testBuildCommit,
                                           repoOrigin: _testRepoOrigin,
                                           isStableBuild: false,
                                           publishingVersion: PublishingInfraVersion.Latest,
                                           isReleaseOnlyPackageVersion: true);
 
            _taskLoggingHelper.HasLoggedErrors.Should().BeTrue();
            _buildEngine.BuildErrorEvents.Should().Contain(e => e.Message.Equals($"Missing 'RelativePdbPath' property on pdb {pdbArtifact}"));
        }
 
        /// <summary>
        /// Test that a build without initial location information is rejected
        /// </summary>
        [Fact]
        public void MissingLocationInformationThrowsError()
        {
            var localPackagePath = TestInputs.GetFullPath(Path.Combine("Nupkgs", "test-package-a.zip"));
 
            var artifacts = new ITaskItem[]
            {
                new TaskItem(localPackagePath, new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "NonShipping=true" },
                    { "Kind", "Package" }
                })
            };
 
            _buildModelFactory.CreateModel(artifacts: artifacts,
                                           artifactVisibilitiesToInclude: ArtifactVisibility.All,
                                           buildId: _testAzdoBuildId,
                                           manifestBuildData: null,
                                           repoUri: _testAzdoRepoUri,
                                           repoBranch: _testBuildBranch,
                                           repoCommit: _testBuildCommit,
                                           repoOrigin: _testRepoOrigin,
                                           isStableBuild: false,
                                           publishingVersion: PublishingInfraVersion.Latest,
                                           isReleaseOnlyPackageVersion: true);
 
            // Should have logged an error that an initial location was not present.
            _taskLoggingHelper.HasLoggedErrors.Should().BeTrue();
            _buildEngine.BuildErrorEvents.Should().Contain(e => e.Message.Equals("Missing 'location' property from ManifestBuildData"));
        }
 
        /// <summary>
        /// Test that a build with initial location attributes in the manifest build data
        /// are accepted.
        /// </summary>
        [Theory]
        [InlineData("Location")]
        [InlineData("InitialAssetsLocation")]
        public void InitialLocationInformationAttributesAreAccepted(string attributeName)
        {
            var localPackagePath = TestInputs.GetFullPath(Path.Combine("Nupkgs", "test-package-a.1.0.0.nupkg"));
 
            var artifacts = new ITaskItem[]
            {
                new TaskItem(localPackagePath, new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "NonShipping=true" },
                    { "Kind", "Package" }
                })
            };
 
            var manifestBuildData = new string[]
            {
                $"{attributeName}={_testInitialLocation}"
            };
 
            var model = _buildModelFactory.CreateModel(artifacts: artifacts,
                                                       artifactVisibilitiesToInclude: ArtifactVisibility.All,
                                                       buildId: _testAzdoBuildId,
                                                       manifestBuildData: manifestBuildData,
                                                       repoUri: _testAzdoRepoUri,
                                                       repoBranch: _testBuildBranch,
                                                       repoCommit: _testBuildCommit,
                                                       repoOrigin: _testRepoOrigin,
                                                       isStableBuild: false,
                                                       publishingVersion: PublishingInfraVersion.Latest,
                                                       isReleaseOnlyPackageVersion: true);
 
            // Should have logged an error that an initial location was not present.
            _taskLoggingHelper.HasLoggedErrors.Should().BeFalse();
 
            // Check that the build model has the initial assets location
            model.Identity.Attributes.Should().Contain(attributeName, _normalizedTestInitialLocation);
        }
 
        /// <summary>
        /// Basic round trip from model -> xml -> model has the desired results.
        /// There is already tests for the xml bits of this in the model itself (BuildManifestModel just wraps it
        /// with some file writing).
        /// 
        /// This also tests a few extra cases, like that the additional metadata (e.g. repo uri)
        /// are correctly modeled and preserved.
        /// </summary>
        [Fact]
        public void RoundTripFromTaskItemsToFileToXml()
        {
            var localPackagePath = TestInputs.GetFullPath(Path.Combine("Nupkgs", "test-package-a.1.0.0.nupkg"));
 
            const string bopSymbolsNupkg = "foo/bar/baz/bop.symbols.nupkg";
            string bobSymbolsExpectedId = $"assets/symbols/{Path.GetFileName(bopSymbolsNupkg)}";
            const string bopSnupkg = "foo/bar/baz/bop.symbols.nupkg";
            string bopSnupkgExpectedId = $"assets/symbols/{Path.GetFileName(bopSnupkg)}";
            const string zipArtifact = "foo/bar/baz/bing.zip";
            // New PDB artifact
            const string pdbArtifact = "foo/bar/baz/bing.pdb";
 
            var artifacts = new ITaskItem[]
            {
                new TaskItem(bopSymbolsNupkg, new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "NonShipping=true;Category=SMORKELER" },
                    { "RelativeBlobPath", bobSymbolsExpectedId},
                    { "ThisIsntArtifactMetadata", "YouGoofed!" },
                    { "Kind", "Blob" }
                }),
                new TaskItem(bopSnupkg, new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "NonShipping=false;Category=SNORPKEG;" },
                    { "RelativeBlobPath", bopSnupkgExpectedId},
                    { "Kind", "Blob" },
                }),
                // Include a package and a fake zip too
                // Note that the relative blob path is a "first class" attribute,
                // not parsed from ManifestArtifactData
                new TaskItem(zipArtifact, new Dictionary<string, string>
                {
                    { "RelativeBlobPath", zipArtifact },
                    { "ManifestArtifactData", "ARandomBitOfMAD=" },
                    { "Kind", "Blob" }
                }),
                new TaskItem(localPackagePath, new Dictionary<string, string>()
                {
                    // This isn't recognized or used for a nupkg
                    { "RelativeBlobPath", zipArtifact },
                    { "ManifestArtifactData", "ShouldWePushDaNorpKeg=YES" },
                    { "Kind", "Package" }
                }),
                // New PDB artifact with RelativePdbPath
                new TaskItem(pdbArtifact, new Dictionary<string, string>
                {
                    { "RelativePdbPath", pdbArtifact },
                    { "ManifestArtifactData", "NonShipping=false;Category=PDB" },
                    { "Kind", "PDB" }
                })
            };
 
            string tempXmlFile = Path.GetTempFileName();
            try
            {
                var modelFromItems = _buildModelFactory.CreateModel(artifacts: artifacts,
                                                                    artifactVisibilitiesToInclude: ArtifactVisibility.All,
                                                                    buildId: _testAzdoBuildId,
                                                                    manifestBuildData: _defaultManifestBuildData,
                                                                    repoUri: _testAzdoRepoUri,
                                                                    repoBranch: _testBuildBranch,
                                                                    repoCommit: _testBuildCommit,
                                                                    repoOrigin: _testRepoOrigin,
                                                                    isStableBuild: true,
                                                                    publishingVersion: PublishingInfraVersion.Latest,
                                                                    isReleaseOnlyPackageVersion: false);
 
                modelFromItems.Should().NotBeNull();
                _taskLoggingHelper.HasLoggedErrors.Should().BeFalse();
 
                // Write to file
                _fileSystem.WriteToFile(tempXmlFile, modelFromItems.ToXml().ToString(SaveOptions.DisableFormatting));
 
                // Read the xml file back in and create a model from it.
                var modelFromFile = _buildModelFactory.ManifestFileToModel(tempXmlFile);
 
                // There will be some reordering of the attributes here (they are written to the xml file in
                // a defined order for some properties, then ordered by case).
                // As a result, this comparison isn't exactly the same as some other tests.
                _taskLoggingHelper.HasLoggedErrors.Should().BeFalse();
                modelFromItems.Identity.Name.Should().Be(_testAzdoRepoUri);
                modelFromItems.Identity.BuildId.Should().Be(_testAzdoBuildId);
                modelFromItems.Identity.Commit.Should().Be(_testBuildCommit);
                modelFromItems.Identity.PublishingVersion.Should().Be(VersionTools.BuildManifest.Model.PublishingInfraVersion.Latest);
                modelFromItems.Identity.IsReleaseOnlyPackageVersion.Should().BeFalse();
                modelFromItems.Identity.IsStable.Should().BeTrue();
                modelFromFile.Artifacts.Blobs.Should().SatisfyRespectively(
                    blob =>
                    {
                        blob.Id.Should().Be(bobSymbolsExpectedId);
                        blob.NonShipping.Should().BeTrue();
                        blob.Attributes.Should().Contain("Id", bobSymbolsExpectedId);
                        blob.Attributes.Should().Contain("Category", "SMORKELER");
                        blob.Attributes.Should().Contain("NonShipping", "true");
                        blob.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                    },
                    blob =>
                    {
                        blob.Id.Should().Be(bopSnupkgExpectedId);
                        blob.NonShipping.Should().BeFalse();
                        blob.Attributes.Should().Contain("Id", bopSnupkgExpectedId);
                        blob.Attributes.Should().Contain("Category", "SNORPKEG");
                        blob.Attributes.Should().Contain("NonShipping", "false");
                        blob.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                    },
                    blob =>
                    {
                        blob.Id.Should().Be(zipArtifact);
                        blob.NonShipping.Should().BeFalse();
                        blob.Attributes.Should().Contain("Id", zipArtifact);
                        blob.Attributes.Should().Contain("ARandomBitOfMAD", string.Empty);
                        blob.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                    });
 
                modelFromFile.Artifacts.Packages.Should().SatisfyRespectively(
                    package =>
                    {
                        package.Id.Should().Be("test-package-a");
                        package.Version.Should().Be("1.0.0");
                        package.NonShipping.Should().BeFalse();
                        package.Attributes.Should().Contain("Id", "test-package-a");
                        package.Attributes.Should().Contain("Version", "1.0.0");
                        package.Attributes.Should().Contain("ShouldWePushDaNorpKeg", "YES");
                        package.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                    });
 
                // New verification for the PDB artifact round trip
                modelFromFile.Artifacts.Pdbs.Should().SatisfyRespectively(
                    pdb =>
                    {
                        pdb.Id.Should().Be(pdbArtifact);
                        pdb.NonShipping.Should().BeFalse();
                        pdb.Attributes.Should().Contain("NonShipping", "false");
                        pdb.Attributes.Should().Contain("Category", "PDB");
                        pdb.Attributes.Should().Contain("Id", pdbArtifact);
                        pdb.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                    }
                );
            }
            finally
            {
                if (File.Exists(tempXmlFile))
                {
                    File.Delete(tempXmlFile);
                }
            }
        }
 
        [Fact]
        public void PreserveRepoOrigin()
        {
            var localPackagePath = TestInputs.GetFullPath(Path.Combine("Nupkgs", "test-package-a.1.0.0.nupkg"));
 
            const string bopSymbolsNupkg = "foo/bar/baz/bop.symbols.nupkg";
            string bobSymbolsExpectedId = $"assets/symbols/{Path.GetFileName(bopSymbolsNupkg)}";
            const string zipArtifact = "foo/bar/baz/bing.zip";
            // New PDB artifact
            const string pdbArtifact = "foo/bar/baz/bing.pdb";
 
            var artifacts = new ITaskItem[]
            {
                new TaskItem(bopSymbolsNupkg, new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "NonShipping=true;Category=SMORKELER" },
                    { "RelativeBlobPath", bobSymbolsExpectedId},
                    { "RepoOrigin", "runtime" },
                    { "ThisIsntArtifactMetadata", "YouGoofed!" },
                    { "Kind", "Blob" }
                }),
                // Include a package and a fake zip too
                // Note that the relative blob path is a "first class" attribute,
                // not parsed from ManifestArtifactData
                new TaskItem(zipArtifact, new Dictionary<string, string>
                {
                    { "RelativeBlobPath", zipArtifact },
                    { "ManifestArtifactData", "ARandomBitOfMAD=" },
                    { "Kind", "Blob" }
                }),
                new TaskItem(localPackagePath, new Dictionary<string, string>()
                {
                    // This isn't recognized or used for a nupkg
                    { "RelativeBlobPath", zipArtifact },
                    { "RepoOrigin", "runtime" },
                    { "ManifestArtifactData", "ShouldWePushDaNorpKeg=YES" },
                    { "Kind", "Package" }
                }),
                // New PDB artifact with RelativePdbPath
                new TaskItem(pdbArtifact, new Dictionary<string, string>
                {
                    { "RelativePdbPath", pdbArtifact },
                    { "RepoOrigin", "runtime" },
                    { "ManifestArtifactData", "NonShipping=false;Category=PDB" },
                    { "Kind", "PDB" }
                })
            };
 
            var modelFromItems = _buildModelFactory.CreateModel(artifacts: artifacts,
                                                                artifactVisibilitiesToInclude: ArtifactVisibility.All,
                                                                buildId: _testAzdoBuildId,
                                                                manifestBuildData: _defaultManifestBuildData,
                                                                repoUri: _testAzdoRepoUri,
                                                                repoBranch: _testBuildBranch,
                                                                repoCommit: _testBuildCommit,
                                                                repoOrigin: _testRepoOrigin,
                                                                isStableBuild: true,
                                                                publishingVersion: PublishingInfraVersion.Latest,
                                                                isReleaseOnlyPackageVersion: false);
 
            modelFromItems.Should().NotBeNull();
            _taskLoggingHelper.HasLoggedErrors.Should().BeFalse();
 
            modelFromItems.Artifacts.Blobs.Should().SatisfyRespectively(
                blob =>
                {
                    blob.Id.Should().Be(bobSymbolsExpectedId);
                    blob.NonShipping.Should().BeTrue();
                    blob.Attributes.Should().Contain("Id", bobSymbolsExpectedId);
                    blob.Attributes.Should().Contain("Category", "SMORKELER");
                    blob.Attributes.Should().Contain("NonShipping", "true");
                    blob.Attributes.Should().Contain("RepoOrigin", "runtime");
                },
                blob =>
                {
                    blob.Id.Should().Be(zipArtifact);
                    blob.NonShipping.Should().BeFalse();
                    blob.Attributes.Should().Contain("Id", zipArtifact);
                    blob.Attributes.Should().Contain("ARandomBitOfMAD", string.Empty);
                    blob.Attributes.Should().Contain("RepoOrigin", _testRepoOrigin);
                });
 
            modelFromItems.Artifacts.Packages.Should().SatisfyRespectively(
                package =>
                {
                    package.Id.Should().Be("test-package-a");
                    package.Version.Should().Be("1.0.0");
                    package.NonShipping.Should().BeFalse();
                    package.Attributes.Should().Contain("Id", "test-package-a");
                    package.Attributes.Should().Contain("Version", "1.0.0");
                    package.Attributes.Should().Contain("ShouldWePushDaNorpKeg", "YES");
                    package.Attributes.Should().Contain("RepoOrigin", "runtime");
                });
 
            modelFromItems.Artifacts.Pdbs.Should().SatisfyRespectively(
                pdb =>
                {
                    pdb.Id.Should().Be(pdbArtifact);
                    pdb.NonShipping.Should().BeFalse();
                    pdb.Attributes.Should().Contain("NonShipping", "false");
                    pdb.Attributes.Should().Contain("Category", "PDB");
                    pdb.Attributes.Should().Contain("Id", pdbArtifact);
                    pdb.Attributes.Should().Contain("RepoOrigin", "runtime");
                });
        }
 
        /// <summary>
        /// Validates that errors are logged and null is returned when Kind metadata is missing from an artifact.
        /// </summary>
        [Fact]
        public void CreateModel_MissingKindMetadata_ReturnsNullAndLogsError()
        {
            // Arrange
            var artifacts = new ITaskItem[]
            {
                new TaskItem("missingKindArtifact.nupkg", new Dictionary<string, string>
                {
                    { "ManifestArtifactData", "NonShipping=true;Category=TEST" }
                    // "Kind" metadata is intentionally missing
                })
            };
 
            // Act
            var model = _buildModelFactory.CreateModel(
                artifacts: artifacts,
                artifactVisibilitiesToInclude: ArtifactVisibility.All,
                buildId: _testAzdoBuildId,
                manifestBuildData: _defaultManifestBuildData,
                repoUri: _testAzdoRepoUri,
                repoBranch: _testBuildBranch,
                repoCommit: _testBuildCommit,
                repoOrigin: _testRepoOrigin,
                isStableBuild: false,
                publishingVersion: PublishingInfraVersion.Latest,
                isReleaseOnlyPackageVersion: true);
 
            // Assert
            model.Should().BeNull();
            _taskLoggingHelper.HasLoggedErrors.Should().BeTrue();
            _buildEngine.BuildErrorEvents.Should().Contain(e => e.Message.Contains("Missing 'Kind' property on artifact missingKindArtifact.nupkg"));
        }
 
        [Fact]
        public void CreateMergedModel_NullModels_LogsErrorAndReturnsNull()
        {
            // Act
            var result = _buildModelFactory.CreateMergedModel(null);
 
            // Assert
            _taskLoggingHelper.HasLoggedErrors.Should().BeTrue();
            _buildEngine.BuildErrorEvents.Should().Contain(e => e.Message.Contains("No manifests to merge."));
            result.Should().BeNull();
        }
 
        [Fact]
        public void CreateMergedModel_EmptyModels_LogsErrorAndReturnsNull()
        {
            // Act
            var result = _buildModelFactory.CreateMergedModel(new List<BuildModel>());
 
            // Assert
            _buildEngine.BuildErrorEvents.Should().Contain(e => e.Message.Contains("No manifests to merge."));
            result.Should().BeNull();
        }
 
        [Fact]
        public void CreateMergedModel_SingleModel_ReturnsSameModel()
        {
            // Arrange
            var buildModel = new BuildModel(new BuildIdentity());
 
            // Act
            var result = _buildModelFactory.CreateMergedModel(new List<BuildModel> { buildModel });
 
            // Assert
            result.Should().Be(buildModel);
        }
 
        /*
         * var identity = manifest.Identity;
                if (!string.Equals(refIdentity.AzureDevOpsAccount, identity.AzureDevOpsAccount, StringComparison.OrdinalIgnoreCase) ||
                    !string.Equals(refIdentity.AzureDevOpsBranch, identity.AzureDevOpsBranch, StringComparison.OrdinalIgnoreCase) ||
                    refIdentity.AzureDevOpsBuildDefinitionId != identity.AzureDevOpsBuildDefinitionId ||
                    refIdentity.AzureDevOpsBuildId != identity.AzureDevOpsBuildId ||
                    !string.Equals(refIdentity.AzureDevOpsBuildNumber, identity.AzureDevOpsBuildNumber, StringComparison.OrdinalIgnoreCase) ||
                    !string.Equals(refIdentity.AzureDevOpsProject, identity.AzureDevOpsProject, StringComparison.OrdinalIgnoreCase) ||
                    !string.Equals(refIdentity.AzureDevOpsRepository, identity.AzureDevOpsRepository, StringComparison.OrdinalIgnoreCase) ||
                    !string.Equals(refIdentity.Branch, identity.Branch, StringComparison.OrdinalIgnoreCase) ||
                    !string.Equals(refIdentity.BuildId, identity.BuildId, StringComparison.OrdinalIgnoreCase) ||
                    !string.Equals(refIdentity.InitialAssetsLocation, identity.InitialAssetsLocation, StringComparison.OrdinalIgnoreCase) ||
                    refIdentity.IsReleaseOnlyPackageVersion != identity.IsReleaseOnlyPackageVersion ||
                    refIdentity.IsStable != identity.IsStable ||
                    !string.Equals(refIdentity.ProductVersion, identity.ProductVersion, StringComparison.OrdinalIgnoreCase) ||
                    refIdentity.PublishingVersion != identity.PublishingVersion ||
                    !string.Equals(refIdentity.VersionStamp, identity.VersionStamp, StringComparison.OrdinalIgnoreCase))
                {
                    _log.LogError("Build identity properties are not identical across manifests.");
                    return null;
                }
        */
 
        [Theory]
        [InlineData("AzureDevOpsAccount", "https://dev.azure.com/dnceng", "https://dnceng.visualstudio.com")]
        [InlineData("AzureDevOpsBranch", "refs/heads/main", "main")]
        [InlineData("AzureDevOpsBuildDefinitionId", "100", "1001")]
        [InlineData("AzureDevOpsBuildId", "100", "1001")]
        [InlineData("AzureDevOpsBuildNumber", "20250102.1", "20250102.2")]
        [InlineData("AzureDevOpsProject", "internal", "DevDiv")]
        [InlineData("AzureDevOpsRepository", "dotnet-arcade", "dotnet-roslyn")]
        [InlineData("Branch", "main", "main2")]
        [InlineData("BuildId", "100", "1001")]
        [InlineData("InitialAssetsLocation", "https://dev.azure.com/dnceng/internal/_apis/build/builds/2650337/artifacts", "https://dev.azure.com/dnceng/internal/_apis/build/builds/2650332/artifacts")]
        [InlineData("IsReleaseOnlyPackageVersion", "true", "false")]
        [InlineData("IsStable", "true", "false")]
        [InlineData("ProductVersion", "1", "2")]
        [InlineData("PublishingVersion", "3", "4")]
        [InlineData("VersionStamp", "3", "4")]
        public void CreateMergedModel_MultipleModelsWithDifferentIdentities_LogsErrorAndReturnsNull(string propertyName, string valueA, string valueB)
        {
            var buildIdentityA = new BuildIdentity();
            buildIdentityA.Attributes[propertyName] = valueA;
            var buildIdentityB = new BuildIdentity();
            buildIdentityB.Attributes[propertyName] = valueB;
            // Arrange
            var buildModel1 = new BuildModel(buildIdentityA);
            var buildModel2 = new BuildModel(buildIdentityB);
 
            // Act
            var result = _buildModelFactory.CreateMergedModel(new List<BuildModel> { buildModel1, buildModel2 });
 
            // Assert
            _buildEngine.BuildErrorEvents.Should().Contain(e => e.Message.Contains("Build identity properties are not identical across manifests."));
            result.Should().BeNull();
        }
 
        [Fact]
        public void CreateMergedModel_MultipleModelsWithSameIdentities_MergesArtifacts()
        {
            // Arrange
            var buildIdentityA = new BuildIdentity {
                AzureDevOpsAccount = "https://dev.azure.com/dnceng",
                AzureDevOpsBranch = "refs/heads/main",
                AzureDevOpsBuildDefinitionId = 100,
                AzureDevOpsBuildId = 100000,
                AzureDevOpsBuildNumber = "20250102.1",
                AzureDevOpsProject = "internal",
                AzureDevOpsRepository = "dotnet-arcade",
                Branch = "refs/heads/main", // Typically not seen but should merge properly
                BuildId = "100000",
                InitialAssetsLocation = "https://dev.azure.com/dnceng/internal/_apis/build/builds/100000/artifacts",
                IsReleaseOnlyPackageVersion = false,
                IsStable = false,
                ProductVersion = "1.0.0",
                PublishingVersion = VersionTools.BuildManifest.Model.PublishingInfraVersion.Latest,
                VersionStamp = "1.0.0"
            };
            var buildIdentityB = new BuildIdentity();
            // Copy all attributes from A
            foreach (var kvp in buildIdentityA.Attributes)
            {
                buildIdentityB.Attributes[kvp.Key] = kvp.Value;
            }
 
 
            var buildModel1 = new BuildModel(buildIdentityA)
            {
                Artifacts = new ArtifactSet
                {
                    Blobs = new List<BlobArtifactModel> { new BlobArtifactModel() },
                    Packages = new List<PackageArtifactModel> { new PackageArtifactModel() },
                    Pdbs = new List<PdbArtifactModel> { new PdbArtifactModel() }
                }
            };
            var buildModel2 = new BuildModel(buildIdentityB)
            {
                Artifacts = new ArtifactSet
                {
                    Blobs = new List<BlobArtifactModel> { new BlobArtifactModel() },
                    Packages = new List<PackageArtifactModel> { new PackageArtifactModel() },
                    Pdbs = new List<PdbArtifactModel> { new PdbArtifactModel() }
                }
            };
 
            // Act
            var result = _buildModelFactory.CreateMergedModel(new List<BuildModel> { buildModel1, buildModel2 });
 
            // Assert
            result.Artifacts.Blobs.Count.Should().Be(2);
            result.Artifacts.Packages.Count.Should().Be(2);
            result.Artifacts.Pdbs.Count.Should().Be(2);
 
            // Verify that the attributes of the merged model match the first model
            result.Identity.AzureDevOpsAccount.Should().Be(buildIdentityA.AzureDevOpsAccount);
            result.Identity.AzureDevOpsBranch.Should().Be(buildIdentityA.AzureDevOpsBranch);
            result.Identity.AzureDevOpsBuildDefinitionId.Should().Be(buildIdentityA.AzureDevOpsBuildDefinitionId);
            result.Identity.AzureDevOpsBuildId.Should().Be(buildIdentityA.AzureDevOpsBuildId);
            result.Identity.AzureDevOpsBuildNumber.Should().Be(buildIdentityA.AzureDevOpsBuildNumber);
            result.Identity.AzureDevOpsProject.Should().Be(buildIdentityA.AzureDevOpsProject);
            result.Identity.AzureDevOpsRepository.Should().Be(buildIdentityA.AzureDevOpsRepository);
            result.Identity.Branch.Should().Be(buildIdentityA.Branch);
            result.Identity.BuildId.Should().Be(buildIdentityA.BuildId);
            result.Identity.InitialAssetsLocation.Should().Be(buildIdentityA.InitialAssetsLocation);
            result.Identity.IsReleaseOnlyPackageVersion.Should().Be(buildIdentityA.IsReleaseOnlyPackageVersion);
            result.Identity.IsStable.Should().Be(buildIdentityA.IsStable);
            result.Identity.ProductVersion.Should().Be(buildIdentityA.ProductVersion);
            result.Identity.PublishingVersion.Should().Be(buildIdentityA.PublishingVersion);
            result.Identity.VersionStamp.Should().Be(buildIdentityA.VersionStamp);
        }
        [Fact]
        public void CreateMergedModel_ArtifactsWithAttributes_PreservesAttributes()
        {
            // Arrange
            var buildIdentityA = new BuildIdentity();
            var buildIdentityB = new BuildIdentity();
 
            var blobArtifactA = new BlobArtifactModel
            {
                Id = "blobA",
                NonShipping = true,
                RepoOrigin = "repoA",
                OriginalFile = "OriginalPathA",
                Visibility = ArtifactVisibility.External,
            };
            blobArtifactA.Attributes["AttributeA"] = "ValueA";
            blobArtifactA.Attributes["AttributeB"] = "ValueB";
 
            var blobArtifactB = new BlobArtifactModel
            {
                Id = "blobB",
                NonShipping = false,
                RepoOrigin = "repoB",
                OriginalFile = "OriginalPathB",
                Visibility = ArtifactVisibility.Internal,
            };
            blobArtifactB.Attributes["AttributeC"] = "ValueC";
            blobArtifactB.Attributes["AttributeD"] = "ValueD";
 
            var packageArtifactA = new PackageArtifactModel
            {
                Id = "packageA",
                Version = "1.0.0",
                CouldBeStable = "true",
                NonShipping = false,
                OriginalFile = "OriginalPathA",
                Visibility = ArtifactVisibility.External,
                OriginBuildName = "OriginBuildNameA",
                RepoOrigin = "repoA"
            };
            packageArtifactA.Attributes["AttributeE"] = "ValueE";
            packageArtifactA.Attributes["AttributeF"] = "ValueF";
 
            var packageArtifactB = new PackageArtifactModel
            {
                Id = "packageB",
                Version = "2.0.0",
                CouldBeStable = "false",
                NonShipping = true,
                OriginalFile = "OriginalPathB",
                Visibility = ArtifactVisibility.Internal,
                OriginBuildName = "OriginBuildNameB",
                RepoOrigin = "repoB"
            };
            packageArtifactB.Attributes["AttributeG"] = "ValueG";
            packageArtifactB.Attributes["AttributeH"] = "ValueH";
 
            var pdbArtifactA = new PdbArtifactModel
            {
                Id = "pdbA",
                NonShipping = false,
                OriginalFile = "OriginalPathA",
                Visibility = ArtifactVisibility.External,
                RepoOrigin = "repoA"
            };
            pdbArtifactA.Attributes["AttributeI"] = "ValueI";
            pdbArtifactA.Attributes["AttributeJ"] = "ValueJ";
 
            var pdbArtifactB = new PdbArtifactModel
            {
                Id = "pdbB",
                NonShipping = true,
                OriginalFile = "OriginalPathB",
                Visibility = ArtifactVisibility.Internal,
                RepoOrigin = "repoB"
            };
            pdbArtifactB.Attributes["AttributeK"] = "ValueK";
            pdbArtifactB.Attributes["AttributeL"] = "ValueL";
 
            var buildModel1 = new BuildModel(buildIdentityA)
            {
                Artifacts = new ArtifactSet
                {
                    Blobs = new List<BlobArtifactModel> { blobArtifactA },
                    Packages = new List<PackageArtifactModel> { packageArtifactA },
                    Pdbs = new List<PdbArtifactModel> { pdbArtifactA }
                }
            };
 
            var buildModel2 = new BuildModel(buildIdentityB)
            {
                Artifacts = new ArtifactSet
                {
                    Blobs = new List<BlobArtifactModel> { blobArtifactB },
                    Packages = new List<PackageArtifactModel> { packageArtifactB },
                    Pdbs = new List<PdbArtifactModel> { pdbArtifactB }
                }
            };
 
            // Act
            var result = _buildModelFactory.CreateMergedModel(new List<BuildModel> { buildModel1, buildModel2 });
 
            // Assert
            result.Artifacts.Blobs.Should().SatisfyRespectively(
                blob =>
                {
                    blob.Id.Should().Be("blobA");
                    blob.NonShipping.Should().BeTrue();
                    blob.RepoOrigin.Should().Be("repoA");
                    blob.Visibility.Should().Be(ArtifactVisibility.External);
                    blob.OriginalFile.Should().Be("OriginalPathA");
                    blob.Attributes.Should().Contain("AttributeA", "ValueA");
                    blob.Attributes.Should().Contain("AttributeB", "ValueB");
                },
                blob =>
                {
                    blob.Id.Should().Be("blobB");
                    blob.NonShipping.Should().BeFalse();
                    blob.RepoOrigin.Should().Be("repoB");
                    blob.Visibility.Should().Be(ArtifactVisibility.Internal);
                    blob.OriginalFile.Should().Be("OriginalPathB");
                    blob.Attributes.Should().Contain("AttributeC", "ValueC");
                    blob.Attributes.Should().Contain("AttributeD", "ValueD");
                });
 
            result.Artifacts.Packages.Should().SatisfyRespectively(
                package =>
                {
                    package.Id.Should().Be("packageA");
                    package.Version.Should().Be("1.0.0");
                    package.CouldBeStable.Should().Be("true");
                    package.NonShipping.Should().BeFalse();
                    package.Visibility.Should().Be(ArtifactVisibility.External);
                    package.OriginalFile.Should().Be("OriginalPathA");
                    package.OriginBuildName.Should().Be("OriginBuildNameA");
                    package.RepoOrigin.Should().Be("repoA");
                    package.Attributes.Should().Contain("AttributeE", "ValueE");
                    package.Attributes.Should().Contain("AttributeF", "ValueF");
                },
                package =>
                {
                    package.Id.Should().Be("packageB");
                    package.Version.Should().Be("2.0.0");
                    package.CouldBeStable.Should().Be("false");
                    package.NonShipping.Should().BeTrue();
                    package.Visibility.Should().Be(ArtifactVisibility.Internal);
                    package.OriginalFile.Should().Be("OriginalPathB");
                    package.OriginBuildName.Should().Be("OriginBuildNameB");
                    package.RepoOrigin.Should().Be("repoB");
                    package.Attributes.Should().Contain("AttributeG", "ValueG");
                    package.Attributes.Should().Contain("AttributeH", "ValueH");
                });
 
            result.Artifacts.Pdbs.Should().SatisfyRespectively(
                pdb =>
                {
                    pdb.Id.Should().Be("pdbA");
                    pdb.NonShipping.Should().BeFalse();
                    pdb.RepoOrigin.Should().Be("repoA");
                    pdb.Visibility.Should().Be(ArtifactVisibility.External);
                    pdb.OriginalFile.Should().Be("OriginalPathA");
                    pdb.Attributes.Should().Contain("AttributeI", "ValueI");
                    pdb.Attributes.Should().Contain("AttributeJ", "ValueJ");
                },
                pdb =>
                {
                    pdb.Id.Should().Be("pdbB");
                    pdb.NonShipping.Should().BeTrue();
                    pdb.RepoOrigin.Should().Be("repoB");
                    pdb.Visibility.Should().Be(ArtifactVisibility.Internal);
                    pdb.OriginalFile.Should().Be("OriginalPathB");
                    pdb.Attributes.Should().Contain("AttributeK", "ValueK");
                    pdb.Attributes.Should().Contain("AttributeL", "ValueL");
                });
        }
    }
}