diff --git a/src/CommunityToolkit.Aspire.Hosting.SurrealDb/CommunityToolkit.Aspire.Hosting.SurrealDb.csproj b/src/CommunityToolkit.Aspire.Hosting.SurrealDb/CommunityToolkit.Aspire.Hosting.SurrealDb.csproj index ae8521bdc..f8b7e8ad9 100644 --- a/src/CommunityToolkit.Aspire.Hosting.SurrealDb/CommunityToolkit.Aspire.Hosting.SurrealDb.csproj +++ b/src/CommunityToolkit.Aspire.Hosting.SurrealDb/CommunityToolkit.Aspire.Hosting.SurrealDb.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests.csproj index b45e2004d..7c8909851 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests.csproj +++ b/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests.csproj @@ -4,10 +4,6 @@ CommunityToolkit.Aspire.Hosting.SurrealDb.Tests - - - - diff --git a/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbFunctionalTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbFunctionalTests.cs deleted file mode 100644 index 6dc0f884e..000000000 --- a/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbFunctionalTests.cs +++ /dev/null @@ -1,447 +0,0 @@ -// 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; -using Aspire.Hosting.Utils; -using Bogus; -using CommunityToolkit.Aspire.Testing; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using Polly; -using SurrealDb.Net; -using SurrealDb.Net.Exceptions; -using System.ComponentModel.DataAnnotations.Schema; -using SurrealRecord = SurrealDb.Net.Models.Record; - -namespace CommunityToolkit.Aspire.Hosting.SurrealDb.Tests; - -[RequiresDocker] -public class SurrealDbFunctionalTests(ITestOutputHelper testOutputHelper) -{ - private const int _generatedTodoCount = 10; - private static readonly Todo[] _todoList; - - static SurrealDbFunctionalTests() - { - _todoList = [.. new TodoFaker().Generate(_generatedTodoCount)]; - - int index = 0; - foreach (var todo in _todoList) - { - todo.Id = (Todo.Table, (++index).ToString()); - } - } - - [Fact] - public async Task VerifySurrealDbResource() - { - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - var ct = cts.Token; - await using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); - - var surrealServer = builder.AddSurrealServer("surreal"); - - var db = surrealServer - .AddNamespace("ns") - .AddDatabase("db"); - - await using var app = builder.Build(); - - await app.StartAsync(ct); - - await app.ResourceNotifications.WaitForResourceHealthyAsync(db.Resource.Name, ct); - - var hb = Host.CreateApplicationBuilder(); - - hb.Configuration[$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(ct); - - hb.AddSurrealClient(db.Resource.Name); - - using var host = hb.Build(); - - await host.StartAsync(ct); - - await using var scope = host.Services.CreateAsyncScope(); - await using var client = scope.ServiceProvider.GetRequiredService(); - - await CreateTestData(client, ct); - await AssertTestData(client, ct); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task WithDataShouldPersistStateBetweenUsages(bool useVolume) - { - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - var ct = cts.Token; - - string? volumeName = null; - string? bindMountPath = null; - - const string databasePath = "surrealkv://data/db.db"; - - try - { - using var builder1 = TestDistributedApplicationBuilder.Create(testOutputHelper); - - var password1 = builder1.AddParameter("surreal-password", secret: true); - password1.Resource.Default = new PasswordConstantDefault(); - - var surrealServer1 = - builder1.AddSurrealServer("surreal", path: databasePath, password: password1); - - var db1 = surrealServer1 - .AddNamespace("ns") - .AddDatabase("db"); - - if (useVolume) - { - // Use a deterministic volume name to prevent them from exhausting the machines if deletion fails - volumeName = VolumeNameGenerator.Generate(surrealServer1, - nameof(WithDataShouldPersistStateBetweenUsages)); - - // if the volume already exists (because of a crashing previous run), delete it - DockerUtils.AttemptDeleteDockerVolume(volumeName, throwOnFailure: true); - surrealServer1.WithDataVolume(volumeName); - } - else - { - bindMountPath = Directory.CreateTempSubdirectory().FullName; - surrealServer1.WithDataBindMount(bindMountPath); - - if (!OperatingSystem.IsWindows()) - { - File.SetUnixFileMode(bindMountPath, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | - UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); - } - } - - using (var app = builder1.Build()) - { - await app.StartAsync(ct); - - await app.ResourceNotifications.WaitForResourceHealthyAsync(surrealServer1.Resource.Name, ct); - await app.ResourceNotifications.WaitForResourceHealthyAsync(db1.Resource.Name, ct); - - try - { - var hb = Host.CreateApplicationBuilder(); - - hb.Configuration[$"ConnectionStrings:{db1.Resource.Name}"] = - await db1.Resource.ConnectionStringExpression.GetValueAsync(ct); - - hb.AddSurrealClient(db1.Resource.Name); - - using var host = hb.Build(); - await host.StartAsync(ct); - - await using var scope1 = host.Services.CreateAsyncScope(); - await using var client = scope1.ServiceProvider.GetRequiredService(); - await CreateTestData(client, ct); - await AssertTestData(client, ct); - } - finally - { - // Stops the container, or the Volume would still be in use - await app.StopAsync(ct); - } - } - - using var builder2 = TestDistributedApplicationBuilder.Create(testOutputHelper); - - var password2 = builder2.AddParameter("surreal-password", secret: true); - password2.Resource.Default = new PasswordConstantDefault(); - - var surrealServer2 = - builder2.AddSurrealServer("surreal", path: databasePath, password: password2); - - var db2 = surrealServer2 - .AddNamespace("ns") - .AddDatabase("db"); - - if (useVolume) - { - surrealServer2.WithDataVolume(volumeName); - } - else - { - surrealServer2.WithDataBindMount(bindMountPath!); - } - - using (var app = builder2.Build()) - { - await app.StartAsync(ct); - - await app.ResourceNotifications.WaitForResourceHealthyAsync(surrealServer2.Resource.Name, ct); - await app.ResourceNotifications.WaitForResourceHealthyAsync(db2.Resource.Name, ct); - - try - { - var hb = Host.CreateApplicationBuilder(); - - hb.Configuration[$"ConnectionStrings:{db2.Resource.Name}"] = - await db2.Resource.ConnectionStringExpression.GetValueAsync(ct); - - hb.AddSurrealClient(db2.Resource.Name); - - using var host = hb.Build(); - await host.StartAsync(ct); - await using var scope2 = host.Services.CreateAsyncScope(); - await using var client = scope2.ServiceProvider.GetRequiredService(); - await AssertTestData(client, ct); - } - finally - { - // Stops the container, or the Volume would still be in use - await app.StopAsync(ct); - } - } - } - finally - { - if (volumeName is not null) - { - DockerUtils.AttemptDeleteDockerVolume(volumeName); - } - - if (bindMountPath is not null) - { - try - { - Directory.Delete(bindMountPath, recursive: true); - } - catch - { - // Don't fail test if we can't clean the temporary folder - } - } - } - } - - [Fact] - public async Task VerifyWaitForOnSurrealDbBlocksDependentResources() - { - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); - await using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); - - var healthCheckTcs = new TaskCompletionSource(); - builder.Services.AddHealthChecks().AddAsyncCheck("blocking_check", () => healthCheckTcs.Task); - - var resource = builder.AddSurrealServer("resource") - .WithHealthCheck("blocking_check"); - - var dependentResource = builder.AddSurrealServer("dependentresource") - .WaitFor(resource); - - await using var app = builder.Build(); - - var pendingStart = app.StartAsync(cts.Token); - - var rns = app.Services.GetRequiredService(); - - await rns.WaitForResourceAsync(resource.Resource.Name, KnownResourceStates.Running, cts.Token); - - await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Waiting, cts.Token); - - healthCheckTcs.SetResult(HealthCheckResult.Healthy()); - - await rns.WaitForResourceAsync(resource.Resource.Name, re => re.Snapshot.HealthStatus == HealthStatus.Healthy, cts.Token); - - await rns.WaitForResourceAsync(dependentResource.Resource.Name, KnownResourceStates.Running, cts.Token); - - await pendingStart; - - await app.StopAsync(cts.Token); - } - - [Fact(Skip = "Feature is unstable and blocking the release")] - public async Task VerifyWithInitFiles() - { - // Creates a script that should be executed when the container is initialized. - - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10)); - var pipeline = new ResiliencePipelineBuilder() - .AddRetry(new() { MaxRetryAttempts = 10, BackoffType = DelayBackoffType.Linear, Delay = TimeSpan.FromSeconds(2), ShouldHandle = new PredicateBuilder().Handle() }) - .Build(); - - var initDirPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - - Directory.CreateDirectory(initDirPath); - - var initFilePath = Path.Combine(initDirPath, "init.surql"); - - var surrealNsName = "ns1"; - var surrealDbName = "db1"; - - try - { - await File.WriteAllTextAsync( - initFilePath, - $""" - USE NS {surrealNsName}; - USE DB {surrealDbName}; - - CREATE car SET Brand = "BatMobile"; - """, - cts.Token - ); - - await using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); - -#pragma warning disable CTASPIRE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var surrealServer = builder - .AddSurrealServer("surreal") - .WithInitFiles(initFilePath); -#pragma warning restore CTASPIRE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - var ns = surrealServer.AddNamespace(surrealNsName); - var db = ns.AddDatabase(surrealDbName); - - await using var app = builder.Build(); - - await app.StartAsync(cts.Token); - - var rns = app.Services.GetRequiredService(); - - await rns.WaitForResourceAsync(db.Resource.Name, KnownResourceStates.Running, cts.Token); - - var hb = Host.CreateApplicationBuilder(); - - hb.Configuration.AddInMemoryCollection(new Dictionary - { - [$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(cts.Token) - }); - - hb.AddSurrealClient(db.Resource.Name); - - using var host = hb.Build(); - - await host.StartAsync(cts.Token); - - // Wait until the database is available - await rns.WaitForResourceHealthyAsync(db.Resource.Name, cts.Token); - - await pipeline.ExecuteAsync(async token => - { - await using var client = host.Services.GetRequiredService(); - - var cars = (await client.Select(Car.Table, token)).ToList(); - var car = Assert.Single(cars); - Assert.Equal("BatMobile", car.Brand); - }, cts.Token); - } - finally - { - try - { - Directory.Delete(initDirPath); - } - catch - { - // Don't fail test if we can't clean the temporary folder - } - } - } - - [Fact(Skip = "Feature is unstable and blocking the release")] - public async Task AddDatabaseCreatesDatabaseWithCustomScript() - { - using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); - - using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); - - var surrealNsName = "ns1"; - var surrealDbName = "db1"; - - var surreal = builder.AddSurrealServer("surreal"); - -#pragma warning disable CTASPIRE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - var db = surreal - .AddNamespace(surrealNsName) - .AddDatabase(surrealDbName) - .WithCreationScript( - $""" - DEFINE DATABASE IF NOT EXISTS {surrealDbName}; - USE DATABASE {surrealDbName}; - - DEFINE TABLE todo; - DEFINE FIELD title ON todo TYPE string; - DEFINE FIELD due_by ON todo TYPE datetime; - DEFINE FIELD is_complete ON todo TYPE bool; - """ - ); -#pragma warning restore CTASPIRE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - using var app = builder.Build(); - - await app.StartAsync(cts.Token); - - var hb = Host.CreateApplicationBuilder(); - - hb.Configuration[$"ConnectionStrings:{db.Resource.Name}"] = await db.Resource.ConnectionStringExpression.GetValueAsync(default); - - hb.AddSurrealClient(db.Resource.Name); - - using var host = hb.Build(); - - await host.StartAsync(); - - await app.ResourceNotifications.WaitForResourceHealthyAsync(db.Resource.Name, cts.Token); - - await using var scope = host.Services.CreateAsyncScope(); - await using var client = scope.ServiceProvider.GetRequiredService(); - - await CreateTestData(client, cts.Token); - await AssertTestData(client, cts.Token); - } - - private static async Task CreateTestData(SurrealDbSession client, CancellationToken ct) - { - await client.Insert(Todo.Table, _todoList, ct); - } - - private static async Task AssertTestData(SurrealDbSession client, CancellationToken ct) - { - var records = await client.Select(Todo.Table, ct); - Assert.Equal(_generatedTodoCount, records.Count()); - - var firstRecord = await client.Select((Todo.Table, "1"), ct); - Assert.NotNull(firstRecord); - Assert.Equivalent(firstRecord, _todoList[0]); - } - - private sealed class Todo : SurrealRecord - { - internal const string Table = "todo"; - - [Column("title")] - public string? Title { get; set; } - - [Column("due_by")] - public DateOnly? DueBy { get; set; } = null; - - [Column("is_complete")] - public bool IsComplete { get; set; } = false; - } - - private class TodoFaker : Faker - { - public TodoFaker() - { - RuleFor(o => o.Title, f => f.Lorem.Sentence()); - RuleFor(o => o.DueBy, f => f.Date.SoonDateOnly()); - RuleFor(o => o.IsComplete, f => f.Random.Bool()); - } - } - - private sealed class Car : SurrealRecord - { - internal const string Table = "car"; - - [Column("brand")] - public string Brand { get; set; } = string.Empty; - } -} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbPublicApiTests.cs b/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbPublicApiTests.cs index a1b025547..81209c95d 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbPublicApiTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/SurrealDbPublicApiTests.cs @@ -4,6 +4,7 @@ using Aspire.Hosting; using Aspire.Hosting.Utils; using Microsoft.Extensions.Logging; +using CommunityToolkit.Aspire.Testing; namespace CommunityToolkit.Aspire.Hosting.SurrealDb.Tests; @@ -21,6 +22,22 @@ public void AddSurrealServerContainerShouldThrowWhenBuilderIsNull() Assert.Equal(nameof(builder), exception.ParamName); } + [Fact] + public void AddDatabaseShouldCreateHealthCheckAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSurrealServer("surreal") + .AddNamespace("ns") + .AddDatabase("db"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var healthCheck = Assert.Single(resource.Annotations.OfType()); + Assert.Equal("surreal_ns_db_check", healthCheck.Key); + } + [Fact] public void AddSurrealServerContainerShouldThrowWhenNameIsNull() { @@ -90,9 +107,9 @@ public void WithDataVolumeShouldThrowWhenBuilderIsNull() public void WithDataShouldThrowWhenBuilderIsNull(bool useVolume) { IResourceBuilder builder = null!; - + Func>? action = null; - + if (useVolume) { action = () => builder.WithDataVolume(); @@ -100,28 +117,61 @@ public void WithDataShouldThrowWhenBuilderIsNull(bool useVolume) else { const string source = "/data"; - + action = () => builder.WithDataBindMount(source); } - + var exception = Assert.Throws(action); Assert.Equal(nameof(builder), exception.ParamName); } - + [Fact] public void WithDataBindMountShouldThrowWhenSourceIsNull() { var builder = TestDistributedApplicationBuilder.Create(); var resourceBuilder = builder.AddSurrealServer("surreal"); - + string source = null!; - + var action = () => resourceBuilder.WithDataBindMount(source); - + var exception = Assert.Throws(action); Assert.Equal(nameof(source), exception.ParamName); } - + + [Fact] + public void WithDataVolumeShouldCreateVolumeMountAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + builder.AddSurrealServer("surreal").WithDataVolume("surreal-data"); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var mountAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(ContainerMountType.Volume, mountAnnotation.Type); + Assert.Equal("surreal-data", mountAnnotation.Source); + Assert.Equal("/data", mountAnnotation.Target); + } + + [Fact] + public void WithDataBindMountShouldCreateBindMountAnnotation() + { + var builder = DistributedApplication.CreateBuilder(); + var source = Path.GetTempPath(); + builder.AddSurrealServer("surreal").WithDataBindMount(source); + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var mountAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(ContainerMountType.BindMount, mountAnnotation.Type); + Assert.Equal(source, mountAnnotation.Source); + Assert.Equal("/data", mountAnnotation.Target); + } + [Fact(Skip = "Feature is unstable and blocking the release")] public void WithInitFilesShouldThrowWhenBuilderIsNull() { @@ -154,7 +204,38 @@ public void WithInitFilesShouldThrowWhenSourceIsNullOrEmpty(bool isNull) : Assert.Throws(action); Assert.Equal(nameof(source), exception.ParamName); } - + + [Fact] + public async Task WithInitFilesShouldAddContainerFilesAndEnvironmentVariable() + { + var initDirectory = Directory.CreateTempSubdirectory(); + var initFilePath = Path.Combine(initDirectory.FullName, "init.surql"); + await File.WriteAllTextAsync(initFilePath, "DEFINE TABLE todo;"); + + try + { + var builder = DistributedApplication.CreateBuilder(); +#pragma warning disable CTASPIRE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + builder.AddSurrealServer("surreal").WithInitFiles(initFilePath); +#pragma warning restore CTASPIRE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var containerFilesAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal("/docker-entrypoint-initdb.d", containerFilesAnnotation.DestinationPath); + + var environmentVariables = await resource.GetEnvironmentVariablesAsync(); + Assert.True(environmentVariables.TryGetValue("SURREAL_IMPORT_FILE", out var importFile)); + Assert.Equal("/docker-entrypoint-initdb.d/init.surql", importFile); + } + finally + { + Directory.Delete(initDirectory.FullName, recursive: true); + } + } + [Fact] public void WithLogLevelShouldThrowWhenBuilderIsNull() { @@ -168,6 +249,27 @@ public void WithLogLevelShouldThrowWhenBuilderIsNull() Assert.Equal(nameof(builder), exception.ParamName); } + [Fact] + public void WithCreationScriptShouldCreateDatabaseScriptAnnotation() + { + const string script = "DEFINE TABLE todo;"; + + var builder = DistributedApplication.CreateBuilder(); +#pragma warning disable CTASPIRE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + builder.AddSurrealServer("surreal") + .AddNamespace("ns") + .AddDatabase("db") + .WithCreationScript(script); +#pragma warning restore CTASPIRE002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + using var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + var resource = Assert.Single(appModel.Resources.OfType()); + + var scriptAnnotation = Assert.Single(resource.Annotations.OfType()); + Assert.Equal(script, scriptAnnotation.Script); + } + [Fact] public void WithSurrealistShouldThrowWhenBuilderIsNull() { @@ -279,13 +381,13 @@ public void CtorSurrealServerDatabaseResourceShouldThrowWhenDatabaseNameIsEmpty( string name = "surreal"; string namespaceName = "ns"; - string databaseName = null!; + string databaseName = ""; var password = ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(distributedApplicationBuilder, "password", special: false); var parent = new SurrealDbServerResource("surreal", null, password); var nsParent = new SurrealDbNamespaceResource("ns", namespaceName, parent); var action = () => new SurrealDbDatabaseResource(name, databaseName, nsParent); - var exception = Assert.Throws(action); + var exception = Assert.Throws(action); Assert.Equal(nameof(databaseName), exception.ParamName); }