// 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 ASPIREDOTNETTOOL
#pragma warning disable ASPIREPROJECTS001
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.DependencyInjection;
namespace Aspire.Hosting.EntityFrameworkCore.Tests;
public class AddEFMigrationsTests
{
[Fact]
public void AddEFMigrationsCreatesResource()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!);
Assert.NotNull(migrations);
Assert.IsAssignableFrom<IResourceBuilder<EFMigrationResource>>(migrations);
Assert.Equal("mymigrations", migrations.Resource.Name);
Assert.Equal(project.Resource, migrations.Resource.StartupProjectResource);
Assert.Equal(typeof(TestDbContext).FullName, migrations.Resource.DbContextTypeName);
}
[Fact]
public void AddEFMigrationsWithoutContextTypeCreatesResource()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations = project.AddEFMigrations("mymigrations");
Assert.NotNull(migrations);
Assert.IsAssignableFrom<IResourceBuilder<EFMigrationResource>>(migrations);
Assert.Equal("mymigrations", migrations.Resource.Name);
Assert.Equal(project.Resource, migrations.Resource.StartupProjectResource);
Assert.Null(migrations.Resource.DbContextTypeName);
}
[Fact]
public void AddEFMigrationsWithExplicitContextTypeNameCreatesResource()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!);
Assert.NotNull(migrations);
Assert.IsAssignableFrom<IResourceBuilder<EFMigrationResource>>(migrations);
Assert.Equal("mymigrations", migrations.Resource.Name);
Assert.Equal(project.Resource, migrations.Resource.StartupProjectResource);
Assert.Equal(typeof(TestDbContext).FullName, migrations.Resource.DbContextTypeName);
}
[Fact]
public void AddEFMigrationsWithContextTypeNameStringCreatesResource()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var contextTypeName = "MyApp.Data.ApplicationDbContext";
var migrations = project.AddEFMigrations("mymigrations", contextTypeName);
Assert.NotNull(migrations);
Assert.IsAssignableFrom<IResourceBuilder<EFMigrationResource>>(migrations);
Assert.Equal("mymigrations", migrations.Resource.Name);
Assert.Equal(project.Resource, migrations.Resource.StartupProjectResource);
Assert.Equal(contextTypeName, migrations.Resource.DbContextTypeName);
}
[Fact]
public void AddEFMigrationsAddsResourceToAppModel()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!);
using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var migrationResource = Assert.Single(appModel.Resources.OfType<EFMigrationResource>());
Assert.Equal("mymigrations", migrationResource.Name);
}
[Fact]
public void AddEFMigrationsFromDotnetProjectUsesGeneralizedStartupProject()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddDotnetProject("myproject", "MyProject.csproj", options => options.ExcludeLaunchProfile = true);
var migrations = project.AddEFMigrations("mymigrations");
Assert.Same(project.Resource, migrations.Resource.StartupProjectResource);
#pragma warning disable CS0618
var exception = Assert.Throws<InvalidOperationException>(() =>
{
_ = migrations.Resource.ProjectResource;
});
#pragma warning restore CS0618
Assert.Contains(nameof(EFMigrationResource.StartupProjectResource), exception.Message);
}
[Fact]
public void AddEFMigrationsFromFileBasedAppThrows()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddDotnetProject("myproject", "app.cs", options => options.ExcludeLaunchProfile = true);
var exception = Assert.Throws<InvalidOperationException>(() =>
{
project.AddEFMigrations("mymigrations");
});
Assert.Equal(
"EF Core migrations require a project file. Resource 'myproject' is a file-based app.",
exception.Message);
}
[Fact]
public void LegacyProjectResourcePropertyReturnsOriginalProject()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations = project.AddEFMigrations("mymigrations");
#pragma warning disable CS0618
Assert.Same(project.Resource, migrations.Resource.ProjectResource);
#pragma warning restore CS0618
}
[Fact]
public void AddEFMigrationsForMultipleContextsSucceeds()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations1 = project.AddEFMigrations("migrations1", typeof(TestDbContext).FullName!);
var migrations2 = project.AddEFMigrations("migrations2", typeof(AnotherDbContext).FullName!);
Assert.NotEqual(migrations1.Resource, migrations2.Resource);
Assert.Equal(typeof(TestDbContext).FullName, migrations1.Resource.DbContextTypeName);
Assert.Equal(typeof(AnotherDbContext).FullName, migrations2.Resource.DbContextTypeName);
}
[Fact]
public void AddEFMigrationsForMultipleContextsWithStringNamesSucceeds()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations1 = project.AddEFMigrations("migrations1", "MyApp.Data.AppDbContext");
var migrations2 = project.AddEFMigrations("migrations2", "MyApp.Data.LoggingDbContext");
Assert.NotEqual(migrations1.Resource, migrations2.Resource);
Assert.Equal("MyApp.Data.AppDbContext", migrations1.Resource.DbContextTypeName);
Assert.Equal("MyApp.Data.LoggingDbContext", migrations2.Resource.DbContextTypeName);
}
[Fact]
public void AddEFMigrationsDuplicateContextTypeThrows()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
project.AddEFMigrations("migrations1", typeof(TestDbContext).FullName!);
var exception = Assert.Throws<InvalidOperationException>(() =>
{
project.AddEFMigrations("migrations2", typeof(TestDbContext).FullName!);
});
Assert.Contains("TestDbContext", exception.Message);
Assert.Contains("already been registered", exception.Message);
}
[Fact]
public void AddEFMigrationsDuplicateContextTypeNameStringThrows()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
project.AddEFMigrations("migrations1", "MyApp.Data.AppDbContext");
var exception = Assert.Throws<InvalidOperationException>(() =>
{
project.AddEFMigrations("migrations2", "MyApp.Data.AppDbContext");
});
Assert.Contains("AppDbContext", exception.Message);
Assert.Contains("already been registered", exception.Message);
}
[Fact]
public void AddEFMigrationsWithNullNameThrows()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
Assert.Throws<ArgumentNullException>(() =>
{
project.AddEFMigrations(null!, typeof(TestDbContext).FullName!);
});
}
[Fact]
public void AddEFMigrationsWithEmptyNameThrows()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
Assert.Throws<ArgumentException>(() =>
{
project.AddEFMigrations("", typeof(TestDbContext).FullName!);
});
}
[Fact]
public void AddEFMigrationsWithEmptyContextTypeNameThrows()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
Assert.Throws<ArgumentException>(() =>
{
project.AddEFMigrations("mymigrations", "");
});
}
[Fact]
public void AddEFMigrationsHasResourceSnapshotAnnotation()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!);
var snapshotAnnotation = migrations.Resource.Annotations.OfType<ResourceSnapshotAnnotation>().FirstOrDefault();
Assert.NotNull(snapshotAnnotation);
Assert.Equal("EFMigration", snapshotAnnotation.InitialSnapshot.ResourceType);
Assert.Equal("NotStarted", snapshotAnnotation.InitialSnapshot.State?.Text);
}
[Fact]
public void AddEFMigrationsHasDatabaseIcon()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!);
var iconAnnotation = migrations.Resource.Annotations.OfType<ResourceIconAnnotation>().FirstOrDefault();
Assert.NotNull(iconAnnotation);
Assert.Equal("Database", iconAnnotation.IconName);
}
[Fact]
public void EFMigrationResourceImplementsIResourceWithWaitSupport()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!);
Assert.IsAssignableFrom<IResourceWithWaitSupport>(migrations.Resource);
}
[Fact]
public void EFMigrationResourceHasOptionsProperty()
{
using var builder = TestDistributedApplicationBuilder.Create();
var project = builder.AddProject<Projects.ServiceA>("myproject");
var migrations = project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!);
Assert.False(migrations.Resource.PublishAsMigrationScript);
Assert.False(migrations.Resource.PublishAsMigrationBundle);
}
[Fact]
public async Task ToolResourceInheritsConnectionStringFromMigrationReference()
{
// The connection string the user declares via .WithReference(<db>) lands on the migration
// resource, not on the startup project. The dotnet-ef tool resource must inherit it so the
// design-time DbContext can open the database. Regression test for the
// "The ConnectionString property has not been initialized." failure.
//
// The forwarding under test is operation-mode agnostic, so this asserts in Publish mode where
// references resolve to manifest expressions synchronously. Run mode is intentionally avoided
// because the tool resource also eagerly inherits the startup project's endpoint references,
// and resolving those in Run mode awaits runtime endpoint allocation (a TaskCompletionSource
// only completed by DCP when the app is actually running) — which deadlocks a plain unit test.
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish);
var db = builder.AddResource(new TestDatabaseResource("db1"));
var project = builder.AddProject<Projects.ServiceA>("myproject");
project.AddEFMigrations("mymigrations", typeof(TestDbContext).FullName!)
.WithReference(db);
using var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var toolResource = Assert.Single(appModel.Resources.OfType<DotnetToolResource>(), r => r.Name == "ef-tool-mymigrations");
#pragma warning disable CS0618 // Type or member is obsolete
var env = await toolResource.GetEnvironmentVariableValuesAsync(DistributedApplicationOperation.Publish);
#pragma warning restore CS0618 // Type or member is obsolete
Assert.Equal("{db1.connectionString}", Assert.Contains("ConnectionStrings__db1", env));
}
// Test classes for DbContext types
private sealed class TestDbContext { }
private sealed class AnotherDbContext { }
/// <summary>
/// A minimal test resource that implements IResourceWithConnectionString so it can be used as the
/// target of <c>WithReference</c> on an EF migration resource.
/// </summary>
private sealed class TestDatabaseResource(string name) : Resource(name), IResourceWithConnectionString
{
public ReferenceExpression ConnectionStringExpression =>
ReferenceExpression.Create($"Host=localhost;Database={Name}");
}
}