File: Model\SqliteResourceRepositoryTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.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.Diagnostics;
using Aspire.Dashboard.Tests.Shared;
using Aspire.DashboardService.Proto.V1;
using Google.Protobuf.WellKnownTypes;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Data.Sqlite;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Model;
 
public sealed class SqliteResourceRepositoryTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public async Task Resources_PersistAndReplayWithEquivalentValues()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var resource = CreateResource("api-123", "api");
 
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            var writer = (IResourceRepositoryWriter)repositoryContext.Repository;
            await writer.ReplaceResourcesAsync([resource]);
 
            AssertResource(Assert.Single(repositoryContext.Repository.GetResources()), resource, replicaIndex: 1);
 
            var updated = resource.Clone();
            updated.State = "Running";
            await writer.ApplyChangesAsync([new WatchResourcesChange { Upsert = updated }]);
            Assert.Equal("Running", repositoryContext.Repository.GetResource(resource.Name)!.State);
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        AssertResource(Assert.Single(historicalContext.Repository.GetResources()), resource, replicaIndex: 1, state: "Running");
    }
 
    [Fact]
    public async Task ResourceSubscription_ReceivesUpsertAndDelete()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        using var repositoryContext = CreateRepository(workspace.Path);
        var writer = (IResourceRepositoryWriter)repositoryContext.Repository;
        var subscription = await repositoryContext.Repository.SubscribeResourcesAsync(CancellationToken.None);
        Assert.Empty(subscription.InitialState);
 
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await using var enumerator = subscription.Subscription.GetAsyncEnumerator(cts.Token);
 
        var resource = CreateResource("worker", "worker");
        await writer.ApplyChangesAsync([new WatchResourcesChange { Upsert = resource }]);
        Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout());
        Assert.Equal(ResourceViewModelChangeType.Upsert, Assert.Single(enumerator.Current).ChangeType);
 
        await writer.ApplyChangesAsync([new WatchResourcesChange { Delete = new ResourceDeletion { ResourceName = resource.Name } }]);
        Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout());
        Assert.Equal(ResourceViewModelChangeType.Delete, Assert.Single(enumerator.Current).ChangeType);
        Assert.Empty(repositoryContext.Repository.GetResources());
    }
 
    [Fact]
    public async Task ResourceSubscription_ReplaceResourcesDeletesOmittedResources()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        using var repositoryContext = CreateRepository(workspace.Path);
        var writer = (IResourceRepositoryWriter)repositoryContext.Repository;
        await writer.ReplaceResourcesAsync([CreateResource("api", "api"), CreateResource("worker", "worker")]);
 
        var subscription = await repositoryContext.Repository.SubscribeResourcesAsync(CancellationToken.None);
        Assert.Equal(2, subscription.InitialState.Length);
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await using var enumerator = subscription.Subscription.GetAsyncEnumerator(cts.Token);
 
        await writer.ReplaceResourcesAsync([CreateResource("api", "api")]);
 
        Assert.True(await enumerator.MoveNextAsync().AsTask().DefaultTimeout());
        Assert.Collection(
            enumerator.Current,
            change =>
            {
                Assert.Equal(ResourceViewModelChangeType.Delete, change.ChangeType);
                Assert.Equal("worker", change.Resource.Name);
            },
            change =>
            {
                Assert.Equal(ResourceViewModelChangeType.Upsert, change.ChangeType);
                Assert.Equal("api", change.Resource.Name);
            });
    }
 
    [Fact]
    public async Task ConsoleLogs_SameProcessReplayIsIgnoredAndLineNumbersCanContinueAfterRestart()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var resource = CreateResource("api", "api");
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            var writer = (IResourceRepositoryWriter)repositoryContext.Repository;
            await writer.ReplaceResourcesAsync([resource]);
            await writer.AddConsoleLogsAsync("api", [
                new ConsoleLogLine { LineNumber = 2, Text = "second", IsStdErr = true },
                new ConsoleLogLine { LineNumber = 1, Text = "first" }
            ]);
            await writer.AddConsoleLogsAsync("api", [
                new ConsoleLogLine { LineNumber = 2, Text = "second-updated", IsStdErr = true },
                new ConsoleLogLine { LineNumber = 3, Text = "third" }
            ]);
        }
 
        {
            using var restartedRepositoryContext = CreateRepository(workspace.Path);
            await ((IResourceRepositoryWriter)restartedRepositoryContext.Repository).AddConsoleLogsAsync(
                "api",
                [new ConsoleLogLine { LineNumber = 4, Text = "fourth" }]);
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        var batches = new List<IReadOnlyList<global::Aspire.Dashboard.Model.ResourceLogLine>>();
        await foreach (var batch in historicalContext.Repository.GetConsoleLogs("api", CancellationToken.None))
        {
            batches.Add(batch);
        }
        var lines = Assert.Single(batches);
        Assert.Collection(lines,
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(2, "second", true), line),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "first", false), line),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(3, "third", false), line),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(4, "fourth", false), line));
    }
 
    [Fact]
    public async Task ConsoleLogs_ClearSelectedResourcesPersistsAndSuppressesReplay()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            var writer = (IResourceRepositoryWriter)repositoryContext.Repository;
            await writer.ReplaceResourcesAsync([CreateResource("api", "api"), CreateResource("worker", "worker")]);
            await writer.AddConsoleLogsAsync("api", [
                new ConsoleLogLine { LineNumber = 1, Text = "api-first" },
                new ConsoleLogLine { LineNumber = 2, Text = "api-second" }
            ]);
            await writer.AddConsoleLogsAsync("worker", [
                new ConsoleLogLine { LineNumber = 1, Text = "worker-first" }
            ]);
 
            var clearDate = new DateTime(2025, 2, 8, 10, 16, 8, DateTimeKind.Utc);
            await writer.ClearConsoleLogsAsync(["api"], clearDate);
            await writer.AddConsoleLogsAsync("api", [
                new ConsoleLogLine { LineNumber = 1, Text = "api-first-replayed" },
                new ConsoleLogLine { LineNumber = 3, Text = "2025-02-08T10:16:08Z api-third-before-clear" },
                new ConsoleLogLine { LineNumber = 4, Text = "2025-02-08T10:16:09Z api-fourth-after-clear" }
            ]);
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        var apiBatches = new List<IReadOnlyList<global::Aspire.Dashboard.Model.ResourceLogLine>>();
        await foreach (var batch in historicalContext.Repository.GetConsoleLogs("api", CancellationToken.None))
        {
            apiBatches.Add(batch);
        }
        Assert.Collection(
            Assert.Single(apiBatches),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(4, "2025-02-08T10:16:09Z api-fourth-after-clear", false), line));
 
        var workerBatches = new List<IReadOnlyList<global::Aspire.Dashboard.Model.ResourceLogLine>>();
        await foreach (var batch in historicalContext.Repository.GetConsoleLogs("worker", CancellationToken.None))
        {
            workerBatches.Add(batch);
        }
        Assert.Collection(
            Assert.Single(workerBatches),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "worker-first", false), line));
    }
 
    [Fact]
    public async Task ConsoleLogs_ResetLineNumbersAfterRepositoryRestartArePersisted()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var resource = CreateResource("api", "api");
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            var writer = (IResourceRepositoryWriter)repositoryContext.Repository;
            await writer.ReplaceResourcesAsync([resource]);
            await writer.AddConsoleLogsAsync("api", [
                new ConsoleLogLine { LineNumber = 1, Text = "first" },
                new ConsoleLogLine { LineNumber = 2, Text = "second" }
            ]);
        }
 
        {
            using var restartedRepositoryContext = CreateRepository(workspace.Path);
            var writer = (IResourceRepositoryWriter)restartedRepositoryContext.Repository;
            await writer.ReplaceResourcesAsync([resource]);
            await writer.AddConsoleLogsAsync("api", [
                new ConsoleLogLine { LineNumber = 1, Text = "new-first" },
                new ConsoleLogLine { LineNumber = 2, Text = "new-second" },
                new ConsoleLogLine { LineNumber = 3, Text = "new-third" }
            ]);
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        var batches = new List<IReadOnlyList<global::Aspire.Dashboard.Model.ResourceLogLine>>();
        await foreach (var batch in historicalContext.Repository.GetConsoleLogs("api", CancellationToken.None))
        {
            batches.Add(batch);
        }
 
        Assert.Collection(
            Assert.Single(batches),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "first", false), line),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(2, "second", false), line),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(1, "new-first", false), line),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(2, "new-second", false), line),
            line => Assert.Equal(new global::Aspire.Dashboard.Model.ResourceLogLine(3, "new-third", false), line));
    }
 
    [Fact]
    public async Task ConsoleLogs_LargeBatchRoundTrips()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var logLines = Enumerable.Range(1, 201)
            .Select(lineNumber => new ConsoleLogLine { LineNumber = lineNumber, Text = $"Line {lineNumber}" })
            .ToArray();
 
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            await ((IResourceRepositoryWriter)repositoryContext.Repository).AddConsoleLogsAsync("api", logLines);
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        var batches = new List<IReadOnlyList<global::Aspire.Dashboard.Model.ResourceLogLine>>();
        await foreach (var batch in historicalContext.Repository.GetConsoleLogs("api", CancellationToken.None))
        {
            batches.Add(batch);
        }
        var persistedLines = Assert.Single(batches);
        Assert.Equal(Enumerable.Range(1, 201), persistedLines.Select(line => line.LineNumber));
        Assert.Equal(logLines.Select(line => line.Text), persistedLines.Select(line => line.Content));
    }
 
    [Fact]
    public async Task Resources_LargeBatchRoundTrips()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var resources = Enumerable.Range(1, 201)
            .Select(index => CreateResource($"resource-{index}", $"Resource {index}"))
            .ToArray();
 
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            await ((IResourceRepositoryWriter)repositoryContext.Repository).ReplaceResourcesAsync(resources);
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        var expected = resources
            .OrderBy(resource => resource.Name)
            .Select(resource => (resource.Name, resource.DisplayName));
        var actual = historicalContext.Repository.GetResources()
            .OrderBy(resource => resource.Name)
            .Select(resource => (resource.Name, resource.DisplayName));
        Assert.Equal(expected, actual);
    }
 
    [Fact]
    public async Task ConsoleLogsLoaded_PersistsWithoutLogLines()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            var writer = (IResourceRepositoryWriter)repositoryContext.Repository;
            await writer.ReplaceResourcesAsync([CreateResource("api", "api"), CreateResource("worker", "worker")]);
            Assert.False(repositoryContext.Repository.GetResource("api")!.ConsoleLogsLoaded);
 
            await writer.MarkConsoleLogsLoadedAsync("api");
 
            var readQueries = await CaptureSqlQueriesAsync(() =>
            {
                Assert.True(repositoryContext.Repository.GetResource("api")!.ConsoleLogsLoaded);
                return Task.CompletedTask;
            });
            Assert.Empty(readQueries);
            Assert.False(repositoryContext.Repository.GetResource("worker")!.ConsoleLogsLoaded);
 
            await writer.ApplyChangesAsync([new WatchResourcesChange { Upsert = CreateResource("api", "api") }]);
            Assert.True(repositoryContext.Repository.GetResource("api")!.ConsoleLogsLoaded);
 
            await writer.ReplaceResourcesAsync([CreateResource("api", "api"), CreateResource("worker", "worker")]);
            Assert.True(repositoryContext.Repository.GetResource("api")!.ConsoleLogsLoaded);
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        Assert.True(historicalContext.Repository.GetResource("api")!.ConsoleLogsLoaded);
        Assert.False(historicalContext.Repository.GetResource("worker")!.ConsoleLogsLoaded);
    }
 
    [Fact]
    public async Task Resources_AllFieldsAndRecursiveValuesRoundTrip()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var nestedValue = new Value
        {
            StructValue = new Struct
            {
                Fields =
                {
                    ["name"] = Value.ForString("database"),
                    ["values"] = new Value
                    {
                        ListValue = new ListValue
                        {
                            Values =
                            {
                                Value.ForNumber(42.5),
                                Value.ForBool(true),
                                new Value { NullValue = NullValue.NullValue }
                            }
                        }
                    }
                }
            }
        };
        var resource = CreateResource("api-complete", "api");
        resource.State = "Running";
        resource.StateStyle = "success";
        resource.StartedAt = Timestamp.FromDateTime(DateTime.UnixEpoch.AddMinutes(1));
        resource.StoppedAt = Timestamp.FromDateTime(DateTime.UnixEpoch.AddMinutes(2));
        resource.IsHidden = true;
        resource.SupportsDetailedTelemetry = true;
        resource.IconName = "Box";
        resource.IconVariant = Aspire.DashboardService.Proto.V1.IconVariant.Filled;
        resource.Environment.Add(new EnvironmentVariable { Name = "OPTIONAL", IsFromSpec = true });
        resource.Environment.Add(new EnvironmentVariable { Name = "VALUE", Value = "set" });
        resource.Urls.Add(new Url
        {
            EndpointName = "https",
            FullUrl = "https://api.dev.localhost:5001/path",
            DisplayProperties = new UrlDisplayProperties { SortOrder = 3, DisplayName = "Secure endpoint" }
        });
        resource.Urls.Add(new Url
        {
            EndpointName = "https",
            FullUrl = "https://localhost:5001/path",
            IsInternal = true,
            DisplayProperties = new UrlDisplayProperties { SortOrder = 3, DisplayName = "Secure endpoint" }
        });
        resource.Volumes.Add(new Volume { Source = "data", Target = "/data", MountType = "volume", IsReadOnly = true });
        resource.Relationships.Add(new ResourceRelationship { ResourceName = "database", Type = "Reference" });
        resource.HealthReports.Add(new HealthReport
        {
            Status = HealthStatus.Healthy,
            Key = "ready",
            Description = "Ready",
            Exception = string.Empty,
            LastRunAt = Timestamp.FromDateTime(DateTime.UnixEpoch.AddSeconds(30))
        });
        resource.Properties.Add(new ResourceProperty
        {
            Name = "nested",
            DisplayName = "Nested value",
            Value = nestedValue,
            IsSensitive = true,
            IsHighlighted = true,
            SortOrder = 7
        });
#pragma warning disable CS0612 // ResourceCommand.Parameter must be persisted for compatibility with older AppHosts.
        resource.Commands.Add(new ResourceCommand
        {
            Name = "configure",
            DisplayName = "Configure",
            Parameter = nestedValue.Clone(),
            DisplayDescription = "Configure the resource",
            ConfirmationMessage = "Continue?",
            IsHighlighted = true,
            IconName = "Settings",
            IconVariant = Aspire.DashboardService.Proto.V1.IconVariant.Filled,
            State = ResourceCommandState.Enabled,
            ArgumentInputs =
            {
                new InteractionInput
                {
                    Name = "mode",
                    Label = "Mode",
                    Placeholder = "Select a mode",
                    InputType = InputType.Choice,
                    Required = true,
                    Value = "safe",
                    Description = "Execution mode",
                    EnableDescriptionMarkdown = true,
                    MaxLength = 20,
                    AllowCustomChoice = true,
                    Loading = true,
                    UpdateStateOnChange = true,
                    Disabled = true,
                    MaxFileSize = 1024,
                    AllowMultipleFiles = true,
                    FileFilter = ".json",
                    Options = { ["safe"] = "Safe", ["fast"] = "Fast" },
                    ValidationErrors = { "Choose a mode" }
                }
            }
        });
#pragma warning restore CS0612
 
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            await ((IResourceRepositoryWriter)repositoryContext.Repository).ReplaceResourcesAsync([resource]);
        }
 
        using (var connection = new SqliteConnection($"Data Source={GetDatabasePath(workspace.Path)};Mode=ReadOnly;Pooling=False"))
        {
            connection.Open();
            using var sqliteCommand = connection.CreateCommand();
            sqliteCommand.CommandText = """
                SELECT COUNT(*)
                FROM dashboard_resource_commands
                WHERE json_extract(parameter_value, '$.name') = 'database';
                """;
            Assert.Equal(1L, sqliteCommand.ExecuteScalar());
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        var actual = Assert.Single(historicalContext.Repository.GetResources());
        Assert.Equal("Running", actual.State);
        Assert.Equal("success", actual.StateStyle);
        Assert.Equal(DateTime.UnixEpoch.AddMinutes(1), actual.StartTimeStamp);
        Assert.Equal(DateTime.UnixEpoch.AddMinutes(2), actual.StopTimeStamp);
        Assert.True(actual.SupportsDetailedTelemetry);
        Assert.Equal("Box", actual.IconName);
        Assert.Collection(actual.Environment,
            item =>
            {
                Assert.Equal("OPTIONAL", item.Name);
                Assert.Equal(string.Empty, item.Value);
                Assert.True(item.FromSpec);
            },
            item => Assert.Equal("set", item.Value));
        Assert.Equal(nestedValue, actual.Properties["nested"].Value);
        Assert.True(actual.Properties["nested"].IsValueSensitive);
        Assert.Equal(14, actual.Properties["nested"].SortOrder);
        var command = Assert.Single(actual.Commands);
        Assert.Equal("configure", command.Name);
        var input = Assert.Single(command.ArgumentInputs);
        Assert.Equal("Safe", input.Options["safe"]);
        Assert.Equal("Fast", input.Options["fast"]);
        Assert.Equal("Choose a mode", Assert.Single(input.ValidationErrors));
        Assert.Collection(actual.Urls,
            url =>
            {
                Assert.Equal("https", url.EndpointName);
                Assert.Equal("api.dev.localhost", url.Url.Host);
                Assert.False(url.IsInternal);
            },
            url =>
            {
                Assert.Equal("https", url.EndpointName);
                Assert.Equal("localhost", url.Url.Host);
                Assert.True(url.IsInternal);
            });
        Assert.Equal("/data", Assert.Single(actual.Volumes).Target);
        Assert.Equal("database", Assert.Single(actual.Relationships).ResourceName);
        Assert.Equal("ready", Assert.Single(actual.HealthReports).Name);
    }
 
    [Fact]
    public async Task Resources_DuplicateEndpointUrlsRoundTrip()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var resource = CreateResource("frontend-cqgvshvm", "frontend");
        resource.Urls.AddRange(
        [
            CreateUrl("http", "Online store (http)", "http://frontend-testshop.dev.localhost:5266/"),
            CreateUrl("http", "Online store (http)", "http://localhost:5266/", isInternal: true),
            CreateUrl("https", "Online store (https)", "https://frontend-testshop.dev.localhost:7269/"),
            CreateUrl("https", "Online store (https)", "https://localhost:7269/", isInternal: true),
            CreateUrl("https", "Health", "https://localhost:7269/health", isInternal: true)
        ]);
 
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            await ((IResourceRepositoryWriter)repositoryContext.Repository).ReplaceResourcesAsync([resource]);
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        var actual = Assert.Single(historicalContext.Repository.GetResources());
        Assert.Collection(actual.Urls,
            url => AssertUrl(url, "http", "Online store (http)", "http://frontend-testshop.dev.localhost:5266/", isInternal: false),
            url => AssertUrl(url, "http", "Online store (http)", "http://localhost:5266/", isInternal: true),
            url => AssertUrl(url, "https", "Online store (https)", "https://frontend-testshop.dev.localhost:7269/", isInternal: false),
            url => AssertUrl(url, "https", "Online store (https)", "https://localhost:7269/", isInternal: true),
            url => AssertUrl(url, "https", "Health", "https://localhost:7269/health", isInternal: true));
 
        static void AssertUrl(global::Aspire.Dashboard.Model.UrlViewModel actual, string endpointName, string displayName, string url, bool isInternal)
        {
            Assert.Equal(endpointName, actual.EndpointName);
            Assert.Equal(displayName, actual.DisplayProperties.DisplayName);
            Assert.Equal(url, actual.Url.ToString());
            Assert.Equal(isInternal, actual.IsInternal);
        }
    }
 
    [Fact]
    public async Task Resources_BulkLoadKeepsChildRecordsIsolated()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var resources = new[]
        {
            CreateResourceWithChildren("api", "API", "api-value"),
            CreateResourceWithChildren("worker", "Worker", "worker-value")
        };
 
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            await ((IResourceRepositoryWriter)repositoryContext.Repository).ReplaceResourcesAsync(resources);
        }
 
        using var historicalContext = CreateRepository(workspace.Path, readOnly: true);
        var actualResources = historicalContext.Repository.GetResources().OrderBy(resource => resource.Name).ToList();
        Assert.Collection(actualResources,
            resource => AssertResourceChildren(resource, "api-value"),
            resource => AssertResourceChildren(resource, "worker-value"));
    }
 
    [Fact]
    public async Task Resources_MultipleResourcesArePersistedWithBatchedQueries()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        using var repositoryContext = CreateRepository(workspace.Path);
        var writer = (IResourceRepositoryWriter)repositoryContext.Repository;
 
        var replaceQueries = await CaptureSqlQueriesAsync(() => writer.ReplaceResourcesAsync([
            CreateResourceWithChildren("api", "API", "api-value"),
            CreateResourceWithChildren("worker", "Worker", "worker-value")
        ]));
        AssertBatchedResourceQueries(replaceQueries);
 
        var applyQueries = await CaptureSqlQueriesAsync(() => writer.ApplyChangesAsync([
            new WatchResourcesChange { Upsert = CreateResourceWithChildren("api", "API", "api-updated") },
            new WatchResourcesChange { Upsert = CreateResourceWithChildren("worker", "Worker", "worker-updated") }
        ]));
        AssertBatchedResourceQueries(applyQueries);
 
        var resources = repositoryContext.Repository.GetResources().OrderBy(resource => resource.Name).ToArray();
        Assert.Collection(resources,
            resource => AssertResourceChildren(resource, "api-updated"),
            resource => AssertResourceChildren(resource, "worker-updated"));
    }
 
    [Fact]
    public void Schema_HasNoSerializedResourceColumns()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var databasePath = GetDatabasePath(workspace.Path);
        using (CreateRepository(workspace.Path))
        {
        }
 
        using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False");
        connection.Open();
        using var command = connection.CreateCommand();
        command.CommandText = """
            SELECT COUNT(*)
            FROM sqlite_schema
            WHERE type = 'table' AND name = 'resources';
            """;
        Assert.Equal(0L, command.ExecuteScalar());
 
        command.CommandText = """
            SELECT COUNT(*)
            FROM pragma_table_info('dashboard_resources')
            WHERE name = 'payload' OR upper(type) = 'BLOB';
            """;
        Assert.Equal(0L, command.ExecuteScalar());
    }
 
    [Fact]
    public async Task Values_AreStoredOnOwnerRowsAsValidatedJson()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var databasePath = GetDatabasePath(workspace.Path);
        var resource = CreateResource("api", "API");
        resource.Properties.Add(new ResourceProperty
        {
            Name = "nested",
            Value = new Value
            {
                StructValue = new Struct
                {
                    Fields =
                    {
                        ["name"] = Value.ForString("database"),
                        ["values"] = new Value
                        {
                            ListValue = new ListValue
                            {
                                Values =
                                {
                                    Value.ForNumber(42.5),
                                    Value.ForBool(true),
                                    new Value { NullValue = NullValue.NullValue }
                                }
                            }
                        }
                    }
                }
            }
        });
 
        {
            using var repositoryContext = CreateRepository(workspace.Path);
            await ((IResourceRepositoryWriter)repositoryContext.Repository).ReplaceResourcesAsync([resource]);
        }
 
        using var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False");
        connection.Open();
        using var command = connection.CreateCommand();
        command.CommandText = """
            SELECT COUNT(*)
            FROM dashboard_resource_properties
            WHERE typeof(value) = 'text'
                AND json_valid(value)
                AND json_extract(value, '$.name') = 'database'
                AND json_array_length(value, '$.values') = 3;
            """;
        Assert.Equal(1L, command.ExecuteScalar());
 
        command.CommandText = """
            SELECT COUNT(*)
            FROM sqlite_schema
            WHERE type = 'table'
                AND name IN ('dashboard_values', 'dashboard_value_map_entries', 'dashboard_value_list_items');
            """;
        Assert.Equal(0L, command.ExecuteScalar());
 
        command.CommandText = "UPDATE dashboard_resource_properties SET value = 'invalid' WHERE resource_name = 'api';";
        Assert.Throws<SqliteException>(() => command.ExecuteNonQuery());
    }
 
    [Fact]
    public void Schema_ResourceRepositoryInitializesAllEmbeddedScripts()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var databasePath = GetDatabasePath(workspace.Path);
        using (CreateRepository(workspace.Path))
        {
        }
 
        using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False");
        connection.Open();
        using var command = connection.CreateCommand();
        command.CommandText = """
            SELECT name
            FROM sqlite_schema
            WHERE type = 'table' AND name IN (
                'dashboard_schema',
                'dashboard_resources',
                'telemetry_logs',
                'telemetry_trace_resources',
                'telemetry_traces',
                'telemetry_metric_instruments')
            ORDER BY name;
            """;
 
        using var reader = command.ExecuteReader();
        var tableNames = new List<string>();
        while (reader.Read())
        {
            tableNames.Add(reader.GetString(0));
        }
 
        Assert.Equal(
        [
            "dashboard_resources",
            "dashboard_schema",
            "telemetry_logs",
            "telemetry_metric_instruments",
            "telemetry_trace_resources",
            "telemetry_traces"
        ], tableNames);
    }
 
    [Fact]
    public void Schema_TraceSummaryShapeAndIndexesExist()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var databasePath = GetDatabasePath(workspace.Path);
        using (CreateRepository(workspace.Path))
        {
        }
 
        using var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False");
        connection.Open();
        using var command = connection.CreateCommand();
 
        command.CommandText = "PRAGMA table_info(telemetry_spans);";
        var spanColumnNames = new List<string>();
        using (var columnReader = command.ExecuteReader())
        {
            while (columnReader.Read())
            {
                spanColumnNames.Add(columnReader.GetString(1));
            }
        }
        Assert.DoesNotContain("resource_order_ticks", spanColumnNames);
 
        command.CommandText = "SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = 'telemetry_trace_resources';";
        var traceResourcesSql = Assert.IsType<string>(command.ExecuteScalar());
        Assert.Contains("CHECK (total_spans >= 0)", traceResourcesSql, StringComparison.Ordinal);
 
        command.CommandText = """
            SELECT name
            FROM sqlite_schema
            WHERE type = 'index' AND name IN (
                'ix_telemetry_spans_parent',
                'ix_telemetry_trace_resources_order')
            ORDER BY name;
            """;
 
        var indexNames = new List<string>();
        using (var reader = command.ExecuteReader())
        {
            while (reader.Read())
            {
                indexNames.Add(reader.GetString(0));
            }
        }
 
        Assert.Equal(
        [
            "ix_telemetry_spans_parent",
            "ix_telemetry_trace_resources_order"
        ], indexNames);
 
        command.CommandText = "INSERT INTO telemetry_resources (resource_name) VALUES ('test'); SELECT last_insert_rowid();";
        var resourceId = Assert.IsType<long>(command.ExecuteScalar());
        command.CommandText = """
            INSERT INTO telemetry_traces (
                trace_id, first_span_timestamp_ticks, last_span_end_timestamp_ticks, duration_ticks,
                last_updated_timestamp_ticks, full_name, primary_span_id, has_error, has_gen_ai)
            VALUES ('trace', 1, 2, 1, 2, 'trace', 'span', 0, 0);
            """;
        command.ExecuteNonQuery();
        command.CommandText = $"INSERT INTO telemetry_trace_resources VALUES ('trace', {resourceId}, 1, 0, 0);";
        command.ExecuteNonQuery();
 
        command.CommandText = "UPDATE telemetry_trace_resources SET total_spans = -1;";
        Assert.Throws<SqliteException>(() => command.ExecuteNonQuery());
        command.CommandText = "UPDATE telemetry_trace_resources SET errored_spans = -1;";
        Assert.Throws<SqliteException>(() => command.ExecuteNonQuery());
        command.CommandText = "UPDATE telemetry_trace_resources SET errored_spans = total_spans + 1;";
        Assert.Throws<SqliteException>(() => command.ExecuteNonQuery());
    }
 
    [Fact]
    public void Schema_SpanKindAndStatusLookupsExist()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var databasePath = GetDatabasePath(workspace.Path);
        using (CreateRepository(workspace.Path))
        {
        }
 
        using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False");
        connection.Open();
        using var command = connection.CreateCommand();
        command.CommandText = """
            SELECT kind || ':' || kind_name
            FROM telemetry_span_kinds
            ORDER BY kind;
            """;
        using (var reader = command.ExecuteReader())
        {
            Assert.Equal(
            [
                "0:Unspecified",
                "1:Internal",
                "2:Server",
                "3:Client",
                "4:Producer",
                "5:Consumer"
            ], ReadValues(reader));
        }
 
        command.CommandText = """
            SELECT status || ':' || status_name
            FROM telemetry_span_statuses
            ORDER BY status;
            """;
        using (var reader = command.ExecuteReader())
        {
            Assert.Equal(["0:Unset", "1:Ok", "2:Error"], ReadValues(reader));
        }
 
        command.CommandText = """
            SELECT "table" || ':' || "from" || ':' || "to"
            FROM pragma_foreign_key_list('telemetry_spans')
            WHERE "from" IN ('kind', 'status')
            ORDER BY "from";
            """;
        using (var reader = command.ExecuteReader())
        {
            Assert.Equal(
            [
                "telemetry_span_kinds:kind:kind",
                "telemetry_span_statuses:status:status"
            ], ReadValues(reader));
        }
 
        static List<string> ReadValues(SqliteDataReader reader)
        {
            var values = new List<string>();
            while (reader.Read())
            {
                values.Add(reader.GetString(0));
            }
            return values;
        }
    }
 
    [Fact]
    public void Schema_TelemetryResourceInstanceIdUniquenessPreservesNullAndEmpty()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var databasePath = GetDatabasePath(workspace.Path);
        using (CreateRepository(workspace.Path))
        {
        }
 
        using var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False");
        connection.Open();
        using var command = connection.CreateCommand();
        command.CommandText = "INSERT INTO telemetry_resources (resource_name, instance_id) VALUES ('api', NULL);";
        command.ExecuteNonQuery();
        Assert.Throws<SqliteException>(() => command.ExecuteNonQuery());
 
        command.CommandText = "INSERT INTO telemetry_resources (resource_name, instance_id) VALUES ('api', '');";
        command.ExecuteNonQuery();
        Assert.Throws<SqliteException>(() => command.ExecuteNonQuery());
 
        command.CommandText = "SELECT COUNT(*) FROM telemetry_resources WHERE resource_name = 'api';";
        Assert.Equal(2L, command.ExecuteScalar());
    }
 
    [Fact]
    public void Schema_AllDashboardTablesAreStrict()
    {
        using var workspace = TemporaryWorkspace.Create(testOutputHelper);
        var databasePath = GetDatabasePath(workspace.Path);
        using (CreateRepository(workspace.Path))
        {
        }
 
        using var connection = new SqliteConnection($"Data Source={databasePath};Mode=ReadOnly;Pooling=False");
        connection.Open();
        using var command = connection.CreateCommand();
        command.CommandText = """
            SELECT name
            FROM pragma_table_list
            WHERE schema = 'main'
                AND type = 'table'
                AND name NOT LIKE 'sqlite_%'
                AND strict = 0
            ORDER BY name;
            """;
 
        using var reader = command.ExecuteReader();
        var nonStrictTableNames = new List<string>();
        while (reader.Read())
        {
            nonStrictTableNames.Add(reader.GetString(0));
        }
 
        Assert.Empty(nonStrictTableNames);
    }
 
    private static string GetDatabasePath(string workspacePath) => Path.Combine(workspacePath, "dashboard.db");
 
    private static SqliteRepositoryTestContext<SqliteResourceRepository> CreateRepository(
        string workspacePath,
        bool readOnly = false)
    {
        return SqliteRepositoryTestHelpers.CreateResourceRepository(
            GetDatabasePath(workspacePath),
            new MockKnownPropertyLookup(),
            readOnly);
    }
 
    private static Resource CreateResource(string name, string displayName)
    {
        return new Resource
        {
            Name = name,
            DisplayName = displayName,
            ResourceType = "Project",
            Uid = $"uid-{name}",
            CreatedAt = Timestamp.FromDateTime(DateTime.UnixEpoch)
        };
    }
 
    private static Url CreateUrl(string endpointName, string displayName, string url, bool isInternal = false)
    {
        return new Url
        {
            EndpointName = endpointName,
            FullUrl = url,
            IsInternal = isInternal,
            DisplayProperties = new UrlDisplayProperties { DisplayName = displayName }
        };
    }
 
    private static Resource CreateResourceWithChildren(string name, string displayName, string value)
    {
        var resource = CreateResource(name, displayName);
        resource.Environment.Add(new EnvironmentVariable { Name = "VALUE", Value = value });
        resource.Properties.Add(new ResourceProperty { Name = "property", Value = Value.ForString(value) });
        resource.Commands.Add(new ResourceCommand
        {
            Name = "command",
            DisplayName = "Command",
            ArgumentInputs =
            {
                new InteractionInput
                {
                    Name = "input",
                    Label = "Input",
                    Options = { [value] = value },
                    ValidationErrors = { value }
                }
            }
        });
        return resource;
    }
 
    private static async Task<IReadOnlyList<string>> CaptureSqlQueriesAsync(Func<Task> action)
    {
        var queries = new List<string>();
        using var operation = new Activity("Capture resource persistence queries").Start();
        using var listener = new ActivityListener
        {
            ShouldListenTo = source => source.Name == TracingSqliteConnection.ActivitySourceName,
            Sample = static (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
            ActivityStopped = activity =>
            {
                if (activity.TraceId == operation.TraceId && activity.GetTagItem("db.query.text") is string query)
                {
                    queries.Add(query);
                }
            }
        };
        ActivitySource.AddActivityListener(listener);
 
        await action();
 
        return queries;
    }
 
    private static void AssertBatchedResourceQueries(IReadOnlyList<string> queries)
    {
        Assert.Equal(9, queries.Count);
 
        string[] insertedTables =
        [
            "dashboard_resources",
            "dashboard_resource_environment",
            "dashboard_resource_properties",
            "dashboard_resource_commands",
            "dashboard_resource_command_inputs",
            "dashboard_resource_command_input_options",
            "dashboard_resource_command_input_validation_errors"
        ];
 
        foreach (var table in insertedTables)
        {
            Assert.Single(queries, query => query.TrimStart().StartsWith($"INSERT INTO {table} ", StringComparison.Ordinal));
        }
    }
 
    private static void AssertResourceChildren(global::Aspire.Dashboard.Model.ResourceViewModel resource, string expected)
    {
        Assert.Equal(expected, Assert.Single(resource.Environment).Value);
        Assert.Equal(expected, resource.Properties["property"].Value.StringValue);
        var input = Assert.Single(Assert.Single(resource.Commands).ArgumentInputs);
        Assert.Equal(expected, input.Options[expected]);
        Assert.Equal(expected, Assert.Single(input.ValidationErrors));
    }
 
    private static void AssertResource(global::Aspire.Dashboard.Model.ResourceViewModel actual, Resource expected, int replicaIndex, string? state = null)
    {
        Assert.Equal(expected.Name, actual.Name);
        Assert.Equal(expected.DisplayName, actual.DisplayName);
        Assert.Equal(expected.ResourceType, actual.ResourceType);
        Assert.Equal(expected.Uid, actual.Uid);
        Assert.Equal(replicaIndex, actual.ReplicaIndex);
        Assert.Equal(state, actual.State);
    }
}