diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs new file mode 100644 index 0000000000..9b205de1a9 --- /dev/null +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageConfigurationExtensions.cs @@ -0,0 +1,81 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Configuration extensions for the Azure Table Storage session store +//----------------------------------------------------------------------- + +using Azure.Data.Tables; +using Csla.Blazor.State.AzureTableStorage; +using Csla.State; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Csla.Configuration +{ + /// + /// Implement extension methods to register the Azure Table Storage + /// session store with the CSLA configuration system. + /// + public static class AzureTableStorageConfigurationExtensions + { + /// + /// Configures CSLA to use the Azure Table Storage + /// implementation. + /// + /// The instance. + /// An optional delegate to configure the store. + /// The instance. + /// + /// Call this after AddServerSideBlazor, which registers the default + /// in-memory session store; the last registration wins. The session store + /// is only used when UseInMemoryApplicationContextManager is + /// . Call + /// periodically: besides removing expired sessions, it refreshes the stored + /// last-touched time of live sessions, so other servers do not purge them. + /// + /// Live sessions are kept in memory. Session data is written to the table + /// when a session is saved through , + /// such as when the WebAssembly client sends its state; changes made in + /// place are written with the next save. A session can serialize to at + /// most 704 KiB. + /// + /// + /// The comes from + /// when it is set, + /// otherwise from the keyed service named by + /// , otherwise from the + /// default registered in the container. + /// + /// + /// is . + public static CslaOptions UseAzureTableStorageSessionStore(this CslaOptions config, Action? options = null) + { + ArgumentNullException.ThrowIfNull(config); + + var storeOptions = new AzureTableStorageOptions(); + options?.Invoke(storeOptions); + + // Singletons, because the singleton SessionManager depends on ISessionStore. + config.Services.AddSingleton(storeOptions); + config.Services.TryAddSingleton(TimeProvider.System); + + // The client is resolved privately rather than registered, so the store + // never changes which TableServiceClient the rest of the app receives. + config.Services.AddSingleton(sp => + ActivatorUtilities.CreateInstance( + sp, GetTableServiceClient(sp, sp.GetRequiredService()))); + return config; + } + + internal static TableServiceClient GetTableServiceClient(IServiceProvider serviceProvider, AzureTableStorageOptions options) + { + if (!string.IsNullOrEmpty(options.ConnectionString)) + return new TableServiceClient(options.ConnectionString); + if (options.ServiceKey is not null) + return serviceProvider.GetRequiredKeyedService(options.ServiceKey); + return serviceProvider.GetRequiredService(); + } + } +} diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs new file mode 100644 index 0000000000..041a0b1fdf --- /dev/null +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageOptions.cs @@ -0,0 +1,52 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Options for the Azure Table Storage session store +//----------------------------------------------------------------------- + +namespace Csla.Blazor.State.AzureTableStorage; + +/// +/// Options for configuring the Azure Table Storage +/// implementation. +/// +public class AzureTableStorageOptions +{ + /// + /// Gets or sets the Azure Storage connection string used to + /// create the . + /// + /// + /// Leave or empty to use a + /// that is already + /// registered in the dependency injection container (for example, one + /// registered by .NET Aspire or by managed-identity wiring). + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets the dependency injection service key used to resolve a + /// keyed . + /// + /// + /// Set this when the host registered the client as a keyed service (for + /// example builder.AddKeyedAzureTableClient("state")). Ignored when + /// is supplied. + /// + public object? ServiceKey { get; set; } + + /// + /// Gets or sets the name of the Azure Storage table used to store + /// sessions. Defaults to CslaStateSessionManager. + /// + public string TableName { get; set; } = "CslaStateSessionManager"; + + /// + /// Gets or sets a value used to discriminate sessions belonging to this + /// application when multiple applications share the same storage table. + /// Defaults to an empty string. + /// + public string ApplicationDiscriminator { get; set; } = string.Empty; +} diff --git a/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs new file mode 100644 index 0000000000..f4223d5f53 --- /dev/null +++ b/Source/Csla.Blazor.State.AzureTableStorage/AzureTableStorageSessionStore.cs @@ -0,0 +1,270 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Azure Table Storage implementation of ISessionStore +//----------------------------------------------------------------------- + +using System.Collections.Concurrent; +using System.Net; +using Azure; +using Azure.Data.Tables; +using Csla.Properties; +using Csla.Serialization; +using Csla.State; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Csla.Blazor.State.AzureTableStorage; + +/// +/// Azure Table Storage implementation of . +/// +/// +/// +/// Live sessions are kept in memory and returned by reference, so callers +/// can change a session in place. The table is read only when a session is +/// not in memory, such as after a restart. +/// +/// +/// Session data is written to the table only by +/// . +/// refreshes only the stored last-touched time of live sessions, so it never +/// serializes a session that a request may be changing. +/// +/// +/// Read and write failures propagate to the caller, so a failed read is never +/// mistaken for a missing session. +/// +/// +internal sealed partial class AzureTableStorageSessionStore : ISessionStore, IDisposable +{ + // Azure Tables limits a binary property to 64 KiB, so session data is split + // across several columns of this size. + internal const int ChunkSize = 64 * 1024; + // The 1 MiB entity limit can be measured on the request, where binary + // values are base64 encoded (Azurite measures it that way), so 11 chunks + // (704 KiB) is the most that fits however it is measured. + internal const int MaxChunks = 11; + private const string ValueColumn = "Value"; + private const string LastTouchedColumn = nameof(Session.LastTouched); + + private readonly ConcurrentDictionary _sessions = []; + private readonly SemaphoreSlim[] _keyLocks = [.. Enumerable.Range(0, 32).Select(_ => new SemaphoreSlim(1, 1))]; + private readonly string _applicationDiscriminator; + private readonly IServiceScopeFactory _scopeFactory; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly TableClient _tableClient; + + /// + /// Initializes a new instance of the + /// class, creating the + /// storage table if it does not exist. + /// + /// The Azure table service client. + /// The session store options. + /// Creates the scopes the serialization formatter is resolved in. + /// The time provider used to find expired sessions. + /// The logger. + /// When any argument is . + public AzureTableStorageSessionStore( + TableServiceClient tableServiceClient, + AzureTableStorageOptions options, + IServiceScopeFactory scopeFactory, + TimeProvider timeProvider, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(tableServiceClient); + ArgumentNullException.ThrowIfNull(options); + + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _applicationDiscriminator = options.ApplicationDiscriminator; + + _tableClient = tableServiceClient.GetTableClient(options.TableName); + _tableClient.CreateIfNotExists(); + } + + /// + public async ValueTask GetSessionAsync(string key) + { + ArgumentNullException.ThrowIfNull(key); + + if (_sessions.TryGetValue(key, out var session)) + return session; + + LogLoadingSession(key, _tableClient.Name); + var response = await _tableClient.GetEntityIfExistsAsync(key, _applicationDiscriminator).ConfigureAwait(false); + if (!response.HasValue || response.Value is null) + return null; + + // Callers that load the same key at the same time all get the instance + // that is cached first, so they all change the same session in place. + var entity = response.Value; + return _sessions.GetOrAdd(key, _ => Deserialize(ReadData(entity))); + } + + /// + /// The session serializes to more than the store can hold. + public async ValueTask AddOrUpdateSessionAsync(string key, Session session) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(session); + + // The lock keeps the table and the cache in the same order for one key. + var keyLock = GetKeyLock(key); + await keyLock.WaitAsync().ConfigureAwait(false); + try + { + LogSavingSession(key, _tableClient.Name); + await _tableClient.UpsertEntityAsync(CreateEntity(key, session), TableUpdateMode.Replace).ConfigureAwait(false); + _sessions[key] = session; + } + finally + { + keyLock.Release(); + } + } + + /// + public async ValueTask PurgeExpiredSessionsAsync(TimeSpan expiration) + { + ArgumentOutOfRangeException.ThrowIfNegative(expiration.Ticks, nameof(expiration)); + + var cutoff = _timeProvider.GetUtcNow().ToUnixTimeSeconds() - (long)expiration.TotalSeconds; + + // Purging is best effort: one failure should not stop the rest of the sweep. + foreach (var item in _sessions) + { + if (item.Value.LastTouched < cutoff) + { + _sessions.TryRemove(item); + continue; + } + try + { + await RefreshLastTouchedAsync(item.Key).ConfigureAwait(false); + } + catch (RequestFailedException ex) + { + LogRefreshFailed(ex, item.Key, _tableClient.Name); + } + } + + var filter = TableClient.CreateQueryFilter($"RowKey eq {_applicationDiscriminator} and LastTouched lt {cutoff}"); + var expired = _tableClient.QueryAsync(filter, select: [nameof(TableEntity.PartitionKey), nameof(TableEntity.RowKey)]); + + await foreach (var entity in expired.ConfigureAwait(false)) + { + if (_sessions.ContainsKey(entity.PartitionKey)) + continue; + try + { + // The ETag makes the delete fail if the session was saved after the query. + await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey, entity.ETag).ConfigureAwait(false); + } + catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.PreconditionFailed) + { + // Expected: the session was saved after the query, so it is still in use. + LogPurgeSkipped(entity.PartitionKey, _tableClient.Name); + } + catch (RequestFailedException ex) + { + LogPurgeFailed(ex, entity.PartitionKey, _tableClient.Name); + } + } + } + + /// + /// Releases the locks used to coordinate writes. + /// + public void Dispose() + { + foreach (var keyLock in _keyLocks) + keyLock.Dispose(); + } + + // Writes only the timestamp, so the stored session data is never replaced + // by a copy serialized while a request was changing it. + private async Task RefreshLastTouchedAsync(string key) + { + var keyLock = GetKeyLock(key); + await keyLock.WaitAsync().ConfigureAwait(false); + try + { + // Read the session under the lock, so the timestamp written is that of + // the session the last AddOrUpdateSessionAsync stored. + if (!_sessions.TryGetValue(key, out var session)) + return; + var entity = new TableEntity(key, _applicationDiscriminator) { [LastTouchedColumn] = session.LastTouched }; + await _tableClient.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Merge).ConfigureAwait(false); + } + finally + { + keyLock.Release(); + } + } + + private SemaphoreSlim GetKeyLock(string key) => + _keyLocks[(uint)StringComparer.Ordinal.GetHashCode(key) % (uint)_keyLocks.Length]; + + private TableEntity CreateEntity(string key, Session session) + { + var data = Serialize(session); + if (data.Length > MaxChunks * ChunkSize) + throw new InvalidOperationException( + string.Format(Resources.SessionStoreSessionTooLarge, key, data.Length, MaxChunks * ChunkSize)); + + var entity = new TableEntity(key, _applicationDiscriminator) { [LastTouchedColumn] = session.LastTouched }; + var chunkCount = Math.Max(1, (data.Length + ChunkSize - 1) / ChunkSize); + for (var index = 0; index < chunkCount; index++) + { + var offset = index * ChunkSize; + entity[GetValueColumn(index)] = data.AsSpan(offset, Math.Min(ChunkSize, data.Length - offset)).ToArray(); + } + return entity; + } + + private static byte[] ReadData(TableEntity entity) + { + var chunks = new List(); + for (var index = 0; index < MaxChunks && entity.GetBinary(GetValueColumn(index)) is { } chunk; index++) + chunks.Add(chunk); + return chunks.Count == 1 ? chunks[0] : [.. chunks.SelectMany(c => c)]; + } + + // The first column keeps the name "Value", so single-column rows still read. + private static string GetValueColumn(int index) => index == 0 ? ValueColumn : ValueColumn + index; + + // The formatter depends on the scoped ApplicationContext and keeps + // per-call state, so each operation resolves its own in a new scope. + private byte[] Serialize(Session session) + { + using var scope = _scopeFactory.CreateScope(); + return scope.ServiceProvider.GetRequiredService().Serialize(session); + } + + private Session Deserialize(byte[] data) + { + using var scope = _scopeFactory.CreateScope(); + return (Session)scope.ServiceProvider.GetRequiredService().Deserialize(data)!; + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Loading session {Key} from table {Table}")] + private partial void LogLoadingSession(string key, string table); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Saving session {Key} to table {Table}")] + private partial void LogSavingSession(string key, string table); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to refresh the last-touched time of session {Key} in table {Table}")] + private partial void LogRefreshFailed(Exception exception, string key, string table); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Kept expired session {Key} in table {Table}, because it was saved after the purge query")] + private partial void LogPurgeSkipped(string key, string table); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to purge expired session {Key} from table {Table}")] + private partial void LogPurgeFailed(Exception exception, string key, string table); +} diff --git a/Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj b/Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj new file mode 100644 index 0000000000..63ddefa581 --- /dev/null +++ b/Source/Csla.Blazor.State.AzureTableStorage/Csla.Blazor.State.AzureTableStorage.csproj @@ -0,0 +1,26 @@ + + + + + net10.0;net11.0 + CSLA .NET Azure Table Storage session store + Azure Table Storage implementation of the CSLA .NET ISessionStore for Blazor server/WASM state. + true + ..\..\Bin + CSLA .NET Azure Table Storage session store + CSLA;Azure;Table Storage;Session;Blazor;State + + + + + + + + + + + + + + + diff --git a/Source/Csla/Properties/Resources.Designer.cs b/Source/Csla/Properties/Resources.Designer.cs index f216c58268..153463ddb5 100644 --- a/Source/Csla/Properties/Resources.Designer.cs +++ b/Source/Csla/Properties/Resources.Designer.cs @@ -1086,6 +1086,15 @@ public static string SessionManagerSessionStateCouldNotBeRetrieved { } } + /// + /// Looks up a localized string similar to Session {0} serializes to {1} bytes, more than the {2} bytes the session store can hold.. + /// + public static string SessionStoreSessionTooLarge { + get { + return ResourceManager.GetString("SessionStoreSessionTooLarge", resourceCulture); + } + } + /// /// Looks up a localized string similar to t. /// diff --git a/Source/Csla/Properties/Resources.resx b/Source/Csla/Properties/Resources.resx index a4110d513b..fac07193a5 100644 --- a/Source/Csla/Properties/Resources.resx +++ b/Source/Csla/Properties/Resources.resx @@ -569,4 +569,8 @@ {0}.Result is not available; it may have been removed by trimming. + + Session {0} serializes to {1} bytes, more than the {2} bytes the session store can hold. + 0 - session key, 1 - serialized size, 2 - maximum size + \ No newline at end of file diff --git a/Source/csla.build.sln b/Source/csla.build.sln index 715961a794..9d4bec918b 100644 --- a/Source/csla.build.sln +++ b/Source/csla.build.sln @@ -69,6 +69,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Testing", "Csla.Testin EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Xaml.Avalonia", "Csla.Xaml.Avalonia\Csla.Xaml.Avalonia.csproj", "{F6080E51-675F-47A8-B125-55DCF66359F8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Blazor.State.AzureTableStorage", "Csla.Blazor.State.AzureTableStorage\Csla.Blazor.State.AzureTableStorage.csproj", "{7BB42513-1D79-474D-B31E-99673AD1BEBF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Ad-Hoc|Any CPU = Ad-Hoc|Any CPU @@ -3973,6 +3975,206 @@ Global {F6080E51-675F-47A8-B125-55DCF66359F8}.Testing|x64.Build.0 = Debug|Any CPU {F6080E51-675F-47A8-B125-55DCF66359F8}.Testing|x86.ActiveCfg = Debug|Any CPU {F6080E51-675F-47A8-B125-55DCF66359F8}.Testing|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Ad-Hoc|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.AppStore|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.CD_ROM|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Debug|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.DVD-5|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Debug|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Mono Release|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Any CPU.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|AnyOS.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|AnyOS.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|ARM.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|ARM.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhone.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhone.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win32.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win32.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win64.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|Win64.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x64.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x64.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x86.ActiveCfg = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Release|x86.Build.0 = Release|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.SingleImage|x86.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Any CPU.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Any CPU.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|AnyOS.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|AnyOS.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|ARM.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|ARM.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhone.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhone.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|iPhoneSimulator.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Mixed Platforms.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Mixed Platforms.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win32.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win32.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|Win64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x64.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x64.Build.0 = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x86.ActiveCfg = Debug|Any CPU + {7BB42513-1D79-474D-B31E-99673AD1BEBF}.Testing|x86.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -4000,6 +4202,7 @@ Global {50A9114F-5D22-43EF-43CF-559234473966} = {580424DE-C982-4E80-A948-EF42FC3AB110} {5ECC7150-E415-4FD6-99A4-08110DB54EE1} = {85799E16-9B3B-4BD5-98D6-238E31291134} {F6080E51-675F-47A8-B125-55DCF66359F8} = {A26EBA9F-1777-46F2-994A-4C4D3055873D} + {7BB42513-1D79-474D-B31E-99673AD1BEBF} = {A26EBA9F-1777-46F2-994A-4C4D3055873D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A9AB8D62-C92D-4E97-A981-46E0A5AF56D1} diff --git a/Source/csla.test.sln b/Source/csla.test.sln index 92623a7e0c..deefcfaffd 100644 --- a/Source/csla.test.sln +++ b/Source/csla.test.sln @@ -95,6 +95,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Xaml.Avalonia", "Csla. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Xaml.Wpf.Tests", "tests\Csla.Xaml.Wpf.Tests\Csla.Xaml.Wpf.Tests.csproj", "{48FD4B37-2ED2-4221-8DC5-A358C21A2E11}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Blazor.State.AzureTableStorage", "Csla.Blazor.State.AzureTableStorage\Csla.Blazor.State.AzureTableStorage.csproj", "{87583187-4AF8-4707-9D05-9118DA296FBD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.Blazor.State.AzureTableStorage.Tests", "tests\Csla.Blazor.State.AzureTableStorage.Tests\Csla.Blazor.State.AzureTableStorage.Tests.csproj", "{D8D85314-A28F-4916-B3C9-47F030137828}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Csla.AotSmokeTest", "tests\Csla.AotSmokeTest\Csla.AotSmokeTest.csproj", "{3B872CD3-6730-4790-A422-40C5B1D2F4BB}" EndProject Global @@ -1561,6 +1564,86 @@ Global {48FD4B37-2ED2-4221-8DC5-A358C21A2E11}.Testing|x64.Build.0 = Debug|Any CPU {48FD4B37-2ED2-4221-8DC5-A358C21A2E11}.Testing|x86.ActiveCfg = Debug|Any CPU {48FD4B37-2ED2-4221-8DC5-A358C21A2E11}.Testing|x86.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|ARM.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|ARM.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x64.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x64.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x86.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Debug|x86.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|Any CPU.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|ARM.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|ARM.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x64.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x64.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x86.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Debug|x86.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|Any CPU.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|Any CPU.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|ARM.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|ARM.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x64.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x64.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x86.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Mono Release|x86.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|Any CPU.Build.0 = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|ARM.ActiveCfg = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|ARM.Build.0 = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x64.ActiveCfg = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x64.Build.0 = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x86.ActiveCfg = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Release|x86.Build.0 = Release|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|Any CPU.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|Any CPU.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|ARM.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|ARM.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x64.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x64.Build.0 = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x86.ActiveCfg = Debug|Any CPU + {87583187-4AF8-4707-9D05-9118DA296FBD}.Testing|x86.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|ARM.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|ARM.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x64.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x64.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x86.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Debug|x86.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|Any CPU.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|ARM.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|ARM.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x64.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x64.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x86.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Debug|x86.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|Any CPU.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|Any CPU.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|ARM.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|ARM.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x64.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x64.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x86.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Mono Release|x86.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|Any CPU.Build.0 = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|ARM.ActiveCfg = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|ARM.Build.0 = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x64.ActiveCfg = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x64.Build.0 = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x86.ActiveCfg = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Release|x86.Build.0 = Release|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|Any CPU.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|Any CPU.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|ARM.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|ARM.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x64.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x64.Build.0 = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x86.ActiveCfg = Debug|Any CPU + {D8D85314-A28F-4916-B3C9-47F030137828}.Testing|x86.Build.0 = Debug|Any CPU {3B872CD3-6730-4790-A422-40C5B1D2F4BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3B872CD3-6730-4790-A422-40C5B1D2F4BB}.Debug|Any CPU.Build.0 = Debug|Any CPU {3B872CD3-6730-4790-A422-40C5B1D2F4BB}.Debug|ARM.ActiveCfg = Debug|Any CPU @@ -1631,6 +1714,7 @@ Global {50A9114F-5D22-43EF-43CF-559234473966} = {01AA1A57-727E-4665-93FF-6E8BDD316ADE} {45D65B40-1387-4D6F-AFA7-E8AE40E442FC} = {01AA1A57-727E-4665-93FF-6E8BDD316ADE} {8276B605-DBCF-484A-9603-BA1A6201A9F3} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {87583187-4AF8-4707-9D05-9118DA296FBD} = {01AA1A57-727E-4665-93FF-6E8BDD316ADE} {3B872CD3-6730-4790-A422-40C5B1D2F4BB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs new file mode 100644 index 0000000000..3b3f6e763d --- /dev/null +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/AzureTableStorageSessionStoreTests.cs @@ -0,0 +1,299 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// Integration tests for the Azure Table Storage session store (require Azurite) +//----------------------------------------------------------------------- + +using Azure.Data.Tables; +using FluentAssertions; +using Csla.Configuration; +using Csla.State; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Csla.Blazor.State.AzureTableStorage.Tests; + +/// +/// Round-trip tests against a real Azure Table endpoint. They use the +/// well-known Azurite development connection string and are excluded from CI +/// because they require the emulator (or a live storage account) to be running. +/// Start Azurite locally (for example: azurite) before running them. +/// +/// +/// A second store instance over the same table stands in for a restarted or +/// different server: it has no sessions in memory, so it reads the table. +/// +[TestClass] +[TestCategory("SkipOnCIServer")] +public class AzureTableStorageSessionStoreTests +{ + private const string DevelopmentConnectionString = "UseDevelopmentStorage=true"; + private const string Discriminator = "test-app"; + + private ServiceProvider _provider = null!; + private TableServiceClient _serviceClient = null!; + private FakeTimeProvider _timeProvider = null!; + private string _tableName = null!; + + [TestInitialize] + public void TestInitialize() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCsla(); + _provider = services.BuildServiceProvider(); + + // Session.Touch uses the system clock, so the fake clock starts at the real time. + _timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + + // Unique table per test so runs do not collide. Table names must be alphanumeric. + _tableName = "CslaTest" + Guid.NewGuid().ToString("N"); + _serviceClient = new TableServiceClient(DevelopmentConnectionString); + } + + [TestCleanup] + public void TestCleanup() + { + _serviceClient.DeleteTable(_tableName); + _provider.Dispose(); + } + + private AzureTableStorageSessionStore CreateStore(string discriminator = Discriminator) + { + var options = new AzureTableStorageOptions + { + TableName = _tableName, + ApplicationDiscriminator = discriminator, + }; + return new AzureTableStorageSessionStore( + _serviceClient, + options, + _provider.GetRequiredService(), + _timeProvider, + _provider.GetRequiredService>()); + } + + private static long OneDayAgo => DateTimeOffset.UtcNow.AddDays(-1).ToUnixTimeSeconds(); + + private async Task AddStaleEntityAsync(string key, string discriminator) + { + var entity = new TableEntity(key, discriminator) + { + ["Value"] = new byte[] { 0 }, + ["LastTouched"] = OneDayAgo, + }; + await _serviceClient.GetTableClient(_tableName).UpsertEntityAsync(entity); + } + + private async Task GetEntityAsync(string key, string discriminator = Discriminator) + { + var response = await _serviceClient.GetTableClient(_tableName).GetEntityIfExistsAsync(key, discriminator); + return response.HasValue ? response.Value : null; + } + + private async Task EntityExistsAsync(string key, string discriminator = Discriminator) => + await GetEntityAsync(key, discriminator) is not null; + + private static Session CreateSessionOfSize(int approximateBytes) => + new() { ["payload"] = new string('x', approximateBytes) }; + + [TestMethod] + public async Task GetSessionAsync_MissingKey_ReturnsNull() + { + var store = CreateStore(); + + var result = await store.GetSessionAsync("does-not-exist"); + + result.Should().BeNull(); + } + + [TestMethod] + public async Task GetSessionAsync_ReturnsTheStoredInstance() + { + var store = CreateStore(); + var session = new Session { ["v"] = 1 }; + await store.AddOrUpdateSessionAsync("key", session); + + var loaded = await store.GetSessionAsync("key"); + + loaded.Should().BeSameAs(session); + } + + [TestMethod] + public async Task GetSessionAsync_ChangesMadeInPlace_AreVisibleToLaterReads() + { + var store = CreateStore(); + await store.AddOrUpdateSessionAsync("key", new Session()); + + (await store.GetSessionAsync("key"))!["v"] = "changed"; + var loaded = await store.GetSessionAsync("key"); + + loaded!["v"].Should().Be("changed"); + } + + [TestMethod] + public async Task GetSessionAsync_NotInMemory_LoadsFromTable() + { + await CreateStore().AddOrUpdateSessionAsync("key", new Session { ["greeting"] = "hello" }); + var restarted = CreateStore(); + + var first = await restarted.GetSessionAsync("key"); + var second = await restarted.GetSessionAsync("key"); + + first.Should().NotBeNull(); + first!["greeting"].Should().Be("hello"); + second.Should().BeSameAs(first); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_Twice_WritesLatestToTable() + { + var store = CreateStore(); + + await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 }); + await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 2 }); + var loaded = await CreateStore().GetSessionAsync("key"); + + loaded!["v"].Should().Be(2); + } + + [TestMethod] + public async Task SessionsWithSameKey_AreIsolatedByDiscriminator() + { + await CreateStore("app-a").AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "a" }); + await CreateStore("app-b").AddOrUpdateSessionAsync("shared-key", new Session { ["app"] = "b" }); + + (await CreateStore("app-a").GetSessionAsync("shared-key"))!["app"].Should().Be("a"); + (await CreateStore("app-b").GetSessionAsync("shared-key"))!["app"].Should().Be("b"); + } + + [TestMethod] + public async Task PurgeExpiredSessionsAsync_RemovesOnlyThisApplicationsStaleRows() + { + var store = CreateStore(); + await AddStaleEntityAsync("stale", Discriminator); + await AddStaleEntityAsync("stale-other-app", "other-app"); + await store.AddOrUpdateSessionAsync("fresh", new Session { ["v"] = 1 }); + + await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1)); + + (await EntityExistsAsync("stale")).Should().BeFalse(); + (await EntityExistsAsync("stale-other-app", "other-app")).Should().BeTrue(); + (await EntityExistsAsync("fresh")).Should().BeTrue(); + } + + [TestMethod] + public async Task PurgeExpiredSessionsAsync_RefreshesLastTouchedWithoutWritingData() + { + var store = CreateStore(); + var session = new Session { ["v"] = 1 }; + await store.AddOrUpdateSessionAsync("key", session); + await _serviceClient.GetTableClient(_tableName).UpdateEntityAsync( + new TableEntity("key", Discriminator) { ["LastTouched"] = OneDayAgo }, Azure.ETag.All, TableUpdateMode.Merge); + session["v"] = 2; + + await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1)); + + (await GetEntityAsync("key"))!.GetInt64("LastTouched").Should().Be(session.LastTouched); + (await CreateStore().GetSessionAsync("key"))!["v"].Should().Be(1); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_ConcurrentSameKey_CacheAndTableAgree() + { + var store = CreateStore(); + + await Task.WhenAll(Enumerable.Range(0, 20).Select(i => + store.AddOrUpdateSessionAsync("key", new Session { ["v"] = i }).AsTask())); + + var cached = await store.GetSessionAsync("key"); + var stored = await CreateStore().GetSessionAsync("key"); + stored!["v"].Should().Be(cached!["v"]); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_LargerThanOneColumn_RoundTrips() + { + var session = CreateSessionOfSize(200 * 1024); + + await CreateStore().AddOrUpdateSessionAsync("key", session); + var loaded = await CreateStore().GetSessionAsync("key"); + + (await GetEntityAsync("key"))!.ContainsKey("Value3").Should().BeTrue(); + loaded!["payload"].Should().Be(session["payload"]); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_NearSizeLimit_RoundTrips() + { + var session = CreateSessionOfSize(AzureTableStorageSessionStore.MaxChunks * AzureTableStorageSessionStore.ChunkSize - 4096); + + await CreateStore().AddOrUpdateSessionAsync("key", session); + var loaded = await CreateStore().GetSessionAsync("key"); + + loaded!["payload"].Should().Be(session["payload"]); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_OverSizeLimit_ThrowsAndDoesNotCache() + { + var store = CreateStore(); + var session = CreateSessionOfSize(AzureTableStorageSessionStore.MaxChunks * AzureTableStorageSessionStore.ChunkSize + 1024); + + var act = async () => await store.AddOrUpdateSessionAsync("key", session); + + await act.Should().ThrowAsync(); + (await store.GetSessionAsync("key")).Should().BeNull(); + } + + [TestMethod] + public async Task AddOrUpdateSessionAsync_SmallerSession_RemovesLeftoverColumns() + { + var store = CreateStore(); + await store.AddOrUpdateSessionAsync("key", CreateSessionOfSize(200 * 1024)); + + await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 }); + + (await GetEntityAsync("key"))!.ContainsKey("Value1").Should().BeFalse(); + (await CreateStore().GetSessionAsync("key"))!["v"].Should().Be(1); + } + + [TestMethod] + public async Task PurgeExpiredSessionsAsync_RemovesExpiredSessionsFromMemoryAndTable() + { + var store = CreateStore(); + await store.AddOrUpdateSessionAsync("key", new Session { ["v"] = 1 }); + + _timeProvider.Advance(TimeSpan.FromHours(2)); + await store.PurgeExpiredSessionsAsync(TimeSpan.FromHours(1)); + + (await EntityExistsAsync("key")).Should().BeFalse(); + (await store.GetSessionAsync("key")).Should().BeNull(); + } + + [TestMethod] + public async Task Store_ResolvedWithScopeValidation_HandlesConcurrentOperations() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCsla(o => o.UseAzureTableStorageSessionStore(s => + { + s.ConnectionString = DevelopmentConnectionString; + s.TableName = _tableName; + s.ApplicationDiscriminator = Discriminator; + })); + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + var store = provider.GetRequiredService(); + var keys = Enumerable.Range(0, 20).Select(i => $"key-{i}").ToList(); + + await Task.WhenAll(keys.Select(k => store.AddOrUpdateSessionAsync(k, new Session { ["k"] = k }).AsTask())); + var restarted = CreateStore(); + var loaded = await Task.WhenAll(keys.Select(k => restarted.GetSessionAsync(k).AsTask())); + + loaded.Select(s => s!["k"]).Should().Equal(keys); + } +} diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs new file mode 100644 index 0000000000..34699b070d --- /dev/null +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/ConfigurationExtensionsTests.cs @@ -0,0 +1,155 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) Marimer LLC. All rights reserved. +// Website: https://cslanet.com +// +// CI-safe DI registration tests for the Azure Table Storage session store +//----------------------------------------------------------------------- + +using Azure.Data.Tables; +using FluentAssertions; +using Csla.Configuration; +using Csla.State; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Csla.Blazor.State.AzureTableStorage.Tests; + +/// +/// Verifies the registrations made by . +/// No test resolves the session store itself, because its constructor contacts +/// Azure Storage; creating a does not. +/// +[TestClass] +public class ConfigurationExtensionsTests +{ + private const string DevelopmentConnectionString = "UseDevelopmentStorage=true"; + + [TestMethod] + public void UseAzureTableStorageSessionStore_RegistersSingletonISessionStore() + { + var services = new ServiceCollection(); + + new CslaOptions(services).UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString); + + var descriptor = services.Single(d => d.ServiceType == typeof(ISessionStore)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + descriptor.ImplementationFactory.Should().NotBeNull(); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_AfterAddServerSideBlazor_IsLastISessionStoreRegistration() + { + var services = new ServiceCollection(); + + services.AddCsla(o => o + .AddServerSideBlazor(b => b.UseInMemoryApplicationContextManager = false) + .UseAzureTableStorageSessionStore(s => s.ConnectionString = DevelopmentConnectionString)); + + var descriptor = services.Last(d => d.ServiceType == typeof(ISessionStore)); + descriptor.ImplementationType.Should().BeNull(); + descriptor.ImplementationFactory.Should().NotBeNull(); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_RegistersConfiguredOptionsAsSingleton() + { + var services = new ServiceCollection(); + + new CslaOptions(services).UseAzureTableStorageSessionStore(o => + { + o.ConnectionString = DevelopmentConnectionString; + o.TableName = "CustomTable"; + }); + + var descriptor = services.Single(d => d.ServiceType == typeof(AzureTableStorageOptions)); + descriptor.Lifetime.Should().Be(ServiceLifetime.Singleton); + ((AzureTableStorageOptions)descriptor.ImplementationInstance!).TableName.Should().Be("CustomTable"); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_KeepsRegisteredTimeProvider() + { + var services = new ServiceCollection(); + var timeProvider = new FakeTimeProvider(); + services.AddSingleton(timeProvider); + + new CslaOptions(services).UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString); + + services.Single(d => d.ServiceType == typeof(TimeProvider)).ImplementationInstance.Should().BeSameAs(timeProvider); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_DoesNotRegisterTableServiceClient() + { + var services = new ServiceCollection(); + + new CslaOptions(services).UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString); + + services.Should().NotContain(d => d.ServiceType == typeof(TableServiceClient)); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_ReturnsSameConfigInstance() + { + var config = new CslaOptions(new ServiceCollection()); + + var result = config.UseAzureTableStorageSessionStore(o => o.ConnectionString = DevelopmentConnectionString); + + result.Should().BeSameAs(config); + } + + [TestMethod] + public void UseAzureTableStorageSessionStore_NullConfig_Throws() + { + CslaOptions config = null!; + + var act = () => config.UseAzureTableStorageSessionStore(); + + act.Should().Throw(); + } + + [TestMethod] + public void GetTableServiceClient_ConnectionString_IgnoresRegisteredClient() + { + var registered = new TableServiceClient(new Uri("https://registered.table.core.windows.net"), new TableSharedKeyCredential("registered", Convert.ToBase64String(new byte[32]))); + using var provider = new ServiceCollection() + .AddSingleton(registered) + .BuildServiceProvider(); + var options = new AzureTableStorageOptions { ConnectionString = DevelopmentConnectionString }; + + var client = AzureTableStorageConfigurationExtensions.GetTableServiceClient(provider, options); + + client.Should().NotBeSameAs(registered); + client.AccountName.Should().Be("devstoreaccount1"); + } + + [TestMethod] + public void GetTableServiceClient_ServiceKey_ResolvesKeyedClient() + { + var keyed = new TableServiceClient(DevelopmentConnectionString); + using var provider = new ServiceCollection() + .AddKeyedSingleton("state", keyed) + .AddSingleton(new TableServiceClient(DevelopmentConnectionString)) + .BuildServiceProvider(); + var options = new AzureTableStorageOptions { ServiceKey = "state" }; + + var client = AzureTableStorageConfigurationExtensions.GetTableServiceClient(provider, options); + + client.Should().BeSameAs(keyed); + } + + [TestMethod] + public void GetTableServiceClient_NoConnectionStringOrKey_ResolvesDefaultClient() + { + var registered = new TableServiceClient(DevelopmentConnectionString); + using var provider = new ServiceCollection() + .AddSingleton(registered) + .BuildServiceProvider(); + + var client = AzureTableStorageConfigurationExtensions.GetTableServiceClient(provider, new AzureTableStorageOptions()); + + client.Should().BeSameAs(registered); + } +} diff --git a/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj new file mode 100644 index 0000000000..64444e5999 --- /dev/null +++ b/Source/tests/Csla.Blazor.State.AzureTableStorage.Tests/Csla.Blazor.State.AzureTableStorage.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0;net11.0 + false + true + ..\..\Csla\CslaKey.snk + + + + + + + + + + + + + + + + + + +