File: Publishing\DeploymentStateManagerTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIREPIPELINES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
using System.Text.Json.Nodes;
using Aspire.Hosting.Publishing;
using Aspire.Hosting.Publishing.Internal;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Tests.Publishing;
 
public class DeploymentStateManagerTests
{
    [Fact]
    public async Task AcquireSectionAsync_ReturnsEmptySection_WhenStateIsNew()
    {
        var stateManager = CreateFileDeploymentStateManager();
 
        var section = await stateManager.AcquireSectionAsync("Parameters");
 
        Assert.NotNull(section);
        Assert.Equal("Parameters", section.SectionName);
        Assert.Equal(0, section.Version);
        Assert.NotNull(section.Data);
        Assert.Empty(section.Data);
    }
 
    [Fact]
    public async Task SaveSectionAsync_IncrementsVersion_AfterSave()
    {
        var stateManager = CreateFileDeploymentStateManager();
 
        var section1 = await stateManager.AcquireSectionAsync("Parameters");
        {
            section1.Data["key1"] = "value1";
            await stateManager.SaveSectionAsync(section1);
        }
 
        var section2 = await stateManager.AcquireSectionAsync("Parameters");
 
        Assert.Equal(1, section2.Version);
        Assert.Equal("value1", section2.Data["key1"]?.GetValue<string>());
    }
 
    [Fact]
    public async Task SaveSectionAsync_ThrowsException_WhenVersionConflictDetected()
    {
        var stateManager = CreateFileDeploymentStateManager();
 
        // Acquire and save first section
        DeploymentStateSection oldSection;
        var section1 = await stateManager.AcquireSectionAsync("Parameters");
        {
            section1.Data["key1"] = "value1";
            var oldVersion = section1.Version; // Capture version before save
            await stateManager.SaveSectionAsync(section1);
            // Create a copy of the section with the old version to simulate a stale section
            oldSection = new DeploymentStateSection(section1.SectionName, section1.Data, oldVersion);
        }
 
        // Acquire and save the section again, incrementing version
        var section2 = await stateManager.AcquireSectionAsync("Parameters");
        {
            section2.Data["key2"] = "value2";
            await stateManager.SaveSectionAsync(section2);
        }
 
        // Try to save the old section - should throw due to version conflict
        var exception = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await stateManager.SaveSectionAsync(oldSection));
 
        Assert.Contains("Concurrency conflict detected in section 'Parameters'", exception.Message);
    }
 
    [Fact]
    public async Task MultipleSections_CanBeModified_Independently()
    {
        var stateManager = CreateFileDeploymentStateManager();
 
        var parametersSection = await stateManager.AcquireSectionAsync("Parameters");
        var azureSection = await stateManager.AcquireSectionAsync("Azure");
        {
            parametersSection.Data["param1"] = "value1";
            azureSection.Data["resource1"] = "azure-value1";
 
            await stateManager.SaveSectionAsync(parametersSection);
            await stateManager.SaveSectionAsync(azureSection);
        }
 
        var parametersCheck = await stateManager.AcquireSectionAsync("Parameters");
        var azureCheck = await stateManager.AcquireSectionAsync("Azure");
 
        Assert.Equal(1, parametersCheck.Version);
        Assert.Equal(1, azureCheck.Version);
        Assert.Equal("value1", parametersCheck.Data["param1"]?.GetValue<string>());
        Assert.Equal("azure-value1", azureCheck.Data["resource1"]?.GetValue<string>());
    }
    [Fact]
    public async Task ConcurrentSaves_ToDifferentSections_AreSerializedToStorage()
    {
        var sharedSha = Guid.NewGuid().ToString("N");
        var stateManager = CreateFileDeploymentStateManager(sharedSha);
        var tasks = new List<Task>();
 
        // Concurrently save to different sections
        for (int i = 0; i < 10; i++)
        {
            int sectionIndex = i;
            tasks.Add(Task.Run(async () =>
            {
                var section = await stateManager.AcquireSectionAsync($"Section{sectionIndex}");
                section.Data[$"key{sectionIndex}"] = $"value{sectionIndex}";
                await stateManager.SaveSectionAsync(section);
            }));
        }
 
        await Task.WhenAll(tasks);
 
        // Verify all sections were saved correctly by loading with a new state manager
        var verifyManager = CreateFileDeploymentStateManager(sharedSha);
        for (int i = 0; i < 10; i++)
        {
            var section = await verifyManager.AcquireSectionAsync($"Section{i}");
            Assert.Equal($"value{i}", section.Data[$"key{i}"]?.GetValue<string>());
        }
    }
 
    [Fact]
    public async Task AcquireSectionAsync_UsesExclusiveLock_OnFirstLoad()
    {
        var stateManager = CreateFileDeploymentStateManager();
 
        var section1 = await stateManager.AcquireSectionAsync("Parameters");
        {
            section1.Data["key1"] = "value1";
            await stateManager.SaveSectionAsync(section1);
        }
 
        var section2 = await stateManager.AcquireSectionAsync("Parameters");
        var section3 = await stateManager.AcquireSectionAsync("Azure");
 
        Assert.NotNull(section2.Data);
        Assert.Equal("value1", section2.Data["key1"]?.GetValue<string>());
        Assert.Equal(1, section2.Version);
        Assert.Equal(0, section3.Version);
    }
 
    [Fact]
    public async Task DataPersists_AcrossSessions_ButVersionsAreInstanceSpecific()
    {
        var sharedSha = Guid.NewGuid().ToString("N");
        var stateManager = CreateFileDeploymentStateManager(sharedSha);
 
        var section1 = await stateManager.AcquireSectionAsync("Parameters");
        {
            section1.Data["key1"] = "value1";
            await stateManager.SaveSectionAsync(section1);
        }
 
        var stateManager2 = CreateFileDeploymentStateManager(sharedSha);
        var section2 = await stateManager2.AcquireSectionAsync("Parameters");
        {
            // Data persists across manager instances
            Assert.Equal("value1", section2.Data["key1"]?.GetValue<string>());
 
            // But version tracking is per-instance (starts at 0)
            Assert.Equal(0, section2.Version);
 
            section2.Data["key2"] = "value2";
            await stateManager2.SaveSectionAsync(section2);
        }
 
        var stateManager3 = CreateFileDeploymentStateManager(sharedSha);
        var section3 = await stateManager3.AcquireSectionAsync("Parameters");
 
        // Data from both sessions is present
        Assert.Equal("value1", section3.Data["key1"]?.GetValue<string>());
        Assert.Equal("value2", section3.Data["key2"]?.GetValue<string>());
 
        // Version starts at 0 for this new instance
        Assert.Equal(0, section3.Version);
    }
 
    [Fact]
    public async Task StateSection_Dispose_ReleasesLock()
    {
        var stateManager = CreateFileDeploymentStateManager();
 
        _ = await stateManager.AcquireSectionAsync("Parameters");
 
        var section2 = await stateManager.AcquireSectionAsync("Parameters");
 
        Assert.NotNull(section2);
    }
 
    [Fact]
    public async Task BackwardCompatibility_LoadsStateWithoutMetadata()
    {
        var stateManager = CreateFileDeploymentStateManager();
 
        var state = new JsonObject
        {
            ["Parameters:param1"] = "value1",
            ["Azure:resource1"] = "azure-value1"
        };
 
        await stateManager.SaveStateAsync(state);
 
        var parametersSection = await stateManager.AcquireSectionAsync("Parameters");
 
        Assert.Equal(0, parametersSection.Version);
    }
 
    private static FileDeploymentStateManager CreateFileDeploymentStateManager(string? sha = null)
    {
        // Use a unique SHA per test by default to avoid test interference,
        // but allow tests to share state by passing the same SHA
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["AppHost:PathSha256"] = sha ?? Guid.NewGuid().ToString("N")
            })
            .Build();
 
        var hostEnvironment = new TestHostEnvironment { EnvironmentName = "Development" };
        var pipelineOptions = Options.Create(new Hosting.Pipelines.PipelineOptions());
 
        return new FileDeploymentStateManager(
            NullLogger<FileDeploymentStateManager>.Instance,
            configuration,
            hostEnvironment,
            pipelineOptions);
    }
 
    private sealed class TestHostEnvironment : IHostEnvironment
    {
        public string EnvironmentName { get; set; } = "Development";
        public string ApplicationName { get; set; } = "TestApp";
        public string ContentRootPath { get; set; } = string.Empty;
        public IFileProvider ContentRootFileProvider { get; set; } = null!;
    }
}