File: ApplicationModelTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Native.Core.Tests\Aspire.Hosting.Native.Core.Tests.csproj (Aspire.Hosting.Native.Core.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Hosting.Native.Model;
 
namespace Aspire.Hosting.Native.Core.Tests;
 
public class ApplicationModelTests
{
    [Fact]
    public void ResourceIdentityBelongsToItsGeneration()
    {
        using var model = new ApplicationModel();
        var resource = model.AddResource("cache", "example.redis/Redis");
        var snapshot = model.ReadResource(resource);
 
        Assert.Equal(model.GenerationId, resource.GenerationId);
        Assert.NotEqual(Guid.Empty, resource.ResourceId);
        Assert.Equal(resource, snapshot.Handle);
        Assert.Equal("cache", snapshot.Name);
        Assert.Equal("example.redis/Redis", snapshot.TypeId);
        Assert.Empty(snapshot.Dependencies);
    }
 
    [Fact]
    public void DuplicateNamesAreRejectedWithoutChangingDeclarations()
    {
        using var model = new ApplicationModel();
        var cache = model.AddResource("cache", "example.redis/Redis");
 
        Assert.Throws<InvalidOperationException>(() => model.AddResource("CACHE", "example.redis/Redis"));
        Assert.Equal(cache, Assert.Single(model.Inspect().Resources).Handle);
    }
 
    [Theory]
    [InlineData("")]
    [InlineData(" ")]
    [InlineData("1cache")]
    [InlineData("cache_name")]
    [InlineData("../cache")]
    [InlineData("cache\n")]
    public void InvalidNamesDoNotAllocateIdentities(string name)
    {
        using var model = new ApplicationModel();
 
        Assert.Throws<ArgumentException>(() => model.AddResource(name, "example.redis/Redis"));
        Assert.Empty(model.Inspect().Resources);
    }
 
    [Fact]
    public void ResourceNameLimitIsExact()
    {
        using var model = new ApplicationModel();
        var name = new string('a', 128);
 
        var resource = model.AddResource(name, "example.redis/Redis");
        Assert.Equal(name, model.ReadResource(resource).Name);
        Assert.Throws<ArgumentException>(() => model.AddResource(name + "a", "example.redis/Redis"));
        Assert.Single(model.Inspect().Resources);
    }
 
    [Theory]
    [InlineData("")]
    [InlineData("Redis")]
    [InlineData("/Redis")]
    [InlineData("example.redis/")]
    [InlineData("example.redis/Redis/Other")]
    [InlineData("example.redis/Redis ")]
    [InlineData("example.redis/Redis\0")]
    public void InvalidTypeIdentitiesDoNotReserveNames(string typeId)
    {
        using var model = new ApplicationModel();
 
        Assert.Throws<ArgumentException>(() => model.AddResource("cache", typeId));
        model.AddResource("cache", "example.redis/Redis");
        Assert.Single(model.Inspect().Resources);
    }
 
    [Fact]
    public void ForgedAndForeignReferencesAreRejected()
    {
        using var model = new ApplicationModel();
        using var other = new ApplicationModel();
        var cache = model.AddResource("cache", "example.redis/Redis");
        var foreign = other.AddResource("cache", "example.redis/Redis");
 
        Assert.Throws<ArgumentException>(() => model.ReadResource(foreign));
        Assert.Throws<ArgumentException>(() => model.ReadResource(new ResourceHandle(model.GenerationId, Guid.NewGuid())));
        Assert.Throws<ArgumentException>(() => model.AddDependency(cache, foreign));
        Assert.Throws<ArgumentException>(() => model.AddDependency(foreign, cache));
        Assert.Empty(model.ReadResource(cache).Dependencies);
    }
 
    [Fact]
    public void DependencyCyclesAreRejectedAtomically()
    {
        using var model = new ApplicationModel();
        var cache = model.AddResource("cache", "example.redis/Redis");
        var web = model.AddResource("web", "example.javascript/Application");
        var proxy = model.AddResource("proxy", "example.proxy/Proxy");
        model.AddDependency(web, cache);
        model.AddDependency(proxy, web);
 
        Assert.Throws<Aspire.Hosting.Native.Runtime.ExecutionPlanException>(() => model.AddDependency(cache, proxy));
        Assert.Throws<Aspire.Hosting.Native.Runtime.ExecutionPlanException>(() => model.AddDependency(cache, cache));
        Assert.Empty(model.ReadResource(cache).Dependencies);
        Assert.Equal(cache, Assert.Single(model.ReadResource(web).Dependencies));
        Assert.Equal(web, Assert.Single(model.ReadResource(proxy).Dependencies));
    }
 
    [Fact]
    public void SharedDependenciesAndRepeatedDeclarationsArePermitted()
    {
        using var model = new ApplicationModel();
        var cache = model.AddResource("cache", "example.redis/Redis");
        var web = model.AddResource("web", "example.javascript/Application");
        var worker = model.AddResource("worker", "example.javascript/Application");
        model.AddDependency(web, cache);
        model.AddDependency(worker, cache);
        model.AddDependency(web, cache);
 
        Assert.Equal(cache, Assert.Single(model.ReadResource(web).Dependencies));
        Assert.Equal(cache, Assert.Single(model.ReadResource(worker).Dependencies));
    }
 
    [Fact]
    public void InspectionDoesNotSealOrShareMutableStorage()
    {
        using var model = new ApplicationModel();
        var web = model.AddResource("web", "example.javascript/Application");
        var before = model.Inspect();
        var cache = model.AddResource("cache", "example.redis/Redis");
        model.AddDependency(web, cache);
        var after = model.Inspect();
 
        Assert.Equal(web, Assert.Single(before.Resources).Handle);
        Assert.Empty(before.Resources[0].Dependencies);
        Assert.Equal(["cache", "web"], after.Resources.Select(resource => resource.Name));
        Assert.Equal(cache, Assert.Single(after.Resources[1].Dependencies));
    }
 
    [Fact]
    public void SealingIsIdempotentAndRejectsAllComposition()
    {
        using var model = new ApplicationModel();
        var web = model.AddResource("web", "example.javascript/Application");
        var cache = model.AddResource("cache", "example.redis/Redis");
        model.AddDependency(web, cache);
        var snapshot = model.Seal();
 
        Assert.Same(snapshot, model.Seal());
        Assert.Same(snapshot, model.Inspect());
        Assert.Throws<InvalidOperationException>(() => model.AddResource("worker", "example.javascript/Application"));
        Assert.Throws<InvalidOperationException>(() => model.AddDependency(web, cache));
        Assert.Equal(cache, Assert.Single(model.ReadResource(web).Dependencies));
    }
 
    [Fact]
    public void SnapshotsUseOrdinalNameOrdering()
    {
        using var model = new ApplicationModel();
        var web = model.AddResource("web", "example.javascript/Application");
        var zulu = model.AddResource("zulu", "example.redis/Redis");
        var alpha = model.AddResource("alpha", "example.redis/Redis");
        model.AddDependency(web, zulu);
        model.AddDependency(web, alpha);
 
        var snapshot = model.Seal();
        Assert.Equal(["alpha", "web", "zulu"], snapshot.Resources.Select(resource => resource.Name));
        Assert.Equal([alpha, zulu], snapshot.Resources[1].Dependencies);
    }
 
    [Fact]
    public async Task ConcurrentDeclarationsPreserveEveryResource()
    {
        using var model = new ApplicationModel();
        var resources = await Task.WhenAll(Enumerable.Range(0, 128)
            .Select(index => Task.Run(() => model.AddResource($"resource-{index}", "example/Resource"))));
 
        Assert.Equal(128, resources.Select(resource => resource.ResourceId).Distinct().Count());
        Assert.Equal(resources.OrderBy(resource => resource.ResourceId),
            model.Seal().Resources.Select(resource => resource.Handle).OrderBy(resource => resource.ResourceId));
    }
 
    [Fact]
    public async Task ConcurrentDuplicateDeclarationsHaveOneOwner()
    {
        using var model = new ApplicationModel();
        var outcomes = await Task.WhenAll(Enumerable.Range(0, 32).Select(_ => Task.Run(() =>
            Record.Exception(() => model.AddResource("cache", "example.redis/Redis")))));
 
        Assert.Single(outcomes, outcome => outcome is null);
        Assert.All(outcomes.Where(outcome => outcome is not null), outcome => Assert.IsType<InvalidOperationException>(outcome));
        Assert.Single(model.Seal().Resources);
    }
 
    [Fact]
    public void DisposalRevokesAccessWithoutChangingSnapshots()
    {
        var model = new ApplicationModel();
        var cache = model.AddResource("cache", "example.redis/Redis");
        var snapshot = model.Seal();
 
        model.Dispose();
        model.Dispose();
 
        Assert.Throws<ObjectDisposedException>(() => model.ReadResource(cache));
        Assert.Throws<ObjectDisposedException>(model.Inspect);
        Assert.Throws<ObjectDisposedException>(model.Seal);
        Assert.Throws<ObjectDisposedException>(() => model.AddResource("web", "example.javascript/Application"));
        Assert.Throws<ObjectDisposedException>(() => model.AddDependency(cache, cache));
        Assert.Equal(cache, Assert.Single(snapshot.Resources).Handle);
    }
}