Init
This commit is contained in:
39
Prefab.Tests/Constants.cs
Normal file
39
Prefab.Tests/Constants.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace Prefab.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Available trait names that can be used for Xunit's [Trait] attribute.
|
||||
/// </summary>
|
||||
public class TraitName
|
||||
{
|
||||
/// <summary>
|
||||
/// Used to give a test a type of category, usually Unit, or Integration. See <see cref="TraitCategory"/>.
|
||||
/// </summary>
|
||||
public const string Category = nameof(Category);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Available category values that can be used when specifying a test with a category trait.
|
||||
/// </summary>
|
||||
public class TraitCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates that the type of test is a unit test.
|
||||
/// </summary>
|
||||
public const string Unit = nameof(Unit);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the type of test is an integration test.
|
||||
/// </summary>
|
||||
public const string Integration = nameof(Integration);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the type of test is a workflow test.
|
||||
/// </summary>
|
||||
public const string Workflow = nameof(Workflow);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the type of test is an end-to-end test.
|
||||
/// </summary>
|
||||
public const string E2E = nameof(E2E);
|
||||
public const string EndToEnd = nameof(EndToEnd);
|
||||
}
|
||||
111
Prefab.Tests/Infrastructure/Aspire/AppHost.cs
Normal file
111
Prefab.Tests/Infrastructure/Aspire/AppHost.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure.Aspire;
|
||||
|
||||
internal sealed class AppHost(DistributedApplication application, EnvironmentVariableScope? environmentScope) : IAsyncDisposable
|
||||
{
|
||||
public DistributedApplication Application => application;
|
||||
|
||||
public static async Task<AppHost> StartAsync(IReadOnlyDictionary<string, string?>? environmentOverrides, CancellationToken cancellationToken)
|
||||
{
|
||||
EnvironmentVariableScope? scope = null;
|
||||
if (environmentOverrides is not null && environmentOverrides.Count > 0)
|
||||
{
|
||||
scope = new EnvironmentVariableScope(environmentOverrides);
|
||||
}
|
||||
|
||||
DistributedApplication? application = null;
|
||||
try
|
||||
{
|
||||
string[] defaultArgs =
|
||||
[
|
||||
"SqlServer:UseDataVolume=false",
|
||||
"SqlServer:HostPort=",
|
||||
"Parameters:prefab-sql-password=PrefabTests!1234",
|
||||
];
|
||||
|
||||
var builder = await DistributedApplicationTestingBuilder
|
||||
.CreateAsync<Projects.Prefab_AppHost>(defaultArgs, static (_, hostOptions) =>
|
||||
{
|
||||
hostOptions ??= new HostApplicationBuilderSettings();
|
||||
hostOptions.Configuration ??= new ConfigurationManager();
|
||||
}, cancellationToken);
|
||||
|
||||
application = await builder.BuildAsync(cancellationToken);
|
||||
await application.StartAsync(cancellationToken);
|
||||
|
||||
var notifications = application.ResourceNotifications;
|
||||
await notifications.WaitForResourceHealthyAsync("prefab-sql", cancellationToken);
|
||||
await notifications.WaitForResourceHealthyAsync("prefab-catalog", cancellationToken);
|
||||
await notifications.WaitForResourceHealthyAsync("prefab-web", cancellationToken);
|
||||
|
||||
return new AppHost(application, scope);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (application is not null)
|
||||
{
|
||||
await application.DisposeAsync();
|
||||
}
|
||||
|
||||
scope?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public HttpClient CreateHttpClient(string resourceName) =>
|
||||
application.CreateHttpClient(resourceName);
|
||||
|
||||
public async Task<string> GetSqlConnectionStringAsync(string resourceName, CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = await application.GetConnectionStringAsync(resourceName, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException($"SQL resource '{resourceName}' did not provide a connection string.");
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await application.StopAsync(CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort
|
||||
}
|
||||
|
||||
await application.DisposeAsync();
|
||||
environmentScope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EnvironmentVariableScope : IDisposable
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string?> _overrides;
|
||||
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public EnvironmentVariableScope(IReadOnlyDictionary<string, string?> overrides)
|
||||
{
|
||||
_overrides = overrides;
|
||||
|
||||
foreach (var pair in overrides)
|
||||
{
|
||||
_originalValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);
|
||||
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var pair in _overrides)
|
||||
{
|
||||
_originalValues.TryGetValue(pair.Key, out var value);
|
||||
Environment.SetEnvironmentVariable(pair.Key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Prefab.Tests/Infrastructure/Aspire/AppHostCollection.cs
Normal file
9
Prefab.Tests/Infrastructure/Aspire/AppHostCollection.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Prefab.Tests.Infrastructure.Aspire;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit collection for tests that share a single Aspire AppHost.
|
||||
/// </summary>
|
||||
[CollectionDefinition("AppHost")]
|
||||
public class AppHostCollection : ICollectionFixture<AppHostFixture>
|
||||
{
|
||||
}
|
||||
117
Prefab.Tests/Infrastructure/Aspire/AppHostFixture.cs
Normal file
117
Prefab.Tests/Infrastructure/Aspire/AppHostFixture.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Web.Data;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure.Aspire;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a reusable Aspire application host for integration tests.
|
||||
/// </summary>
|
||||
public class AppHostFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan DefaultStartupTimeout = TimeSpan.FromMinutes(2);
|
||||
private AppHost? _host;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the Aspire host has been started.
|
||||
/// </summary>
|
||||
public bool IsRunning => _host is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active <see cref="AppHost"/>.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the host has not been started.</exception>
|
||||
private AppHost Host => _host ?? throw new InvalidOperationException("The Aspire host has not been started.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await StartHostAsync(null, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _host.DisposeAsync();
|
||||
_host = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the Aspire host with the supplied environment overrides.
|
||||
/// </summary>
|
||||
/// <param name="environmentOverrides">Environment variables to apply when starting the host.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the restart operation.</param>
|
||||
public async Task ReinitializeAsync(
|
||||
IReadOnlyDictionary<string, string?>? environmentOverrides,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await DisposeAsync();
|
||||
await StartHostAsync(environmentOverrides, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="HttpClient"/> for the specified resource.
|
||||
/// </summary>
|
||||
public HttpClient CreateHttpClient(string resourceName) => Host.CreateHttpClient(resourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a connection string for the specified resource.
|
||||
/// </summary>
|
||||
public Task<string> GetConnectionStringAsync(string resourceName, CancellationToken cancellationToken) =>
|
||||
Host.GetSqlConnectionStringAsync(resourceName, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Provides an <see cref="AppDb"/> configured for write operations so tests can seed data.
|
||||
/// </summary>
|
||||
/// <param name="seedWork">Delegate that performs the seed work.</param>
|
||||
/// <param name="cancellationToken">Token used to cancel the operation.</param>
|
||||
public async Task SeedTestWith(Func<AppDb, CancellationToken, Task> seedWork, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(seedWork);
|
||||
|
||||
var connectionString = await GetConnectionStringAsync("prefab-db", cancellationToken);
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDb>()
|
||||
.UseSqlServer(connectionString)
|
||||
.Options;
|
||||
|
||||
await using var db = new AppDb(options, new HandlerContextAccessor());
|
||||
|
||||
await db.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
await seedWork(db, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Aspire host with the specified environment overrides.
|
||||
/// </summary>
|
||||
private async Task StartHostAsync(IReadOnlyDictionary<string, string?>? environmentOverrides, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_host is not null)
|
||||
{
|
||||
throw new InvalidOperationException("The Aspire host has already been started.");
|
||||
}
|
||||
|
||||
using var cts = cancellationToken.CanBeCanceled
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
: new CancellationTokenSource(DefaultStartupTimeout);
|
||||
|
||||
cts.CancelAfter(DefaultStartupTimeout);
|
||||
|
||||
var overrides = environmentOverrides ?? BuildEnvironmentOverrides();
|
||||
_host = await AppHost.StartAsync(
|
||||
overrides is { Count: > 0 } ? overrides : null,
|
||||
cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides the default environment overrides used when starting the host.
|
||||
/// </summary>
|
||||
protected virtual IReadOnlyDictionary<string, string?> BuildEnvironmentOverrides() =>
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
197
Prefab.Tests/Infrastructure/AspireInfraHost.cs
Normal file
197
Prefab.Tests/Infrastructure/AspireInfraHost.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Aspire AppHost while keeping only infrastructure resources (e.g., SQL, cache).
|
||||
/// </summary>
|
||||
public sealed class AspireInfraHost : IAsyncLifetime
|
||||
{
|
||||
private readonly PrefabHarnessOptions _options;
|
||||
private readonly string[] _infraResourcesToKeep;
|
||||
private DistributedApplication? _app;
|
||||
private readonly Dictionary<string, ProjectResource> _projects = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AspireInfraHost(PrefabHarnessOptions options, params string[] infraResourcesToKeep)
|
||||
{
|
||||
_options = options;
|
||||
_infraResourcesToKeep = infraResourcesToKeep;
|
||||
}
|
||||
|
||||
public DistributedApplication App => _app ?? throw new InvalidOperationException("Aspire is not running.");
|
||||
public IServiceProvider Services => App.Services;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var appArgs = new List<string>();
|
||||
if (_options.Mode == RunMode.EphemeralIsolated)
|
||||
{
|
||||
var containerName = $"prefab-sql-{Guid.NewGuid():N}"[..19];
|
||||
appArgs.Add("SqlServer:UseDataVolume=false");
|
||||
appArgs.Add("SqlServer:HostPort=");
|
||||
appArgs.Add($"SqlServer:ContainerName={containerName}");
|
||||
}
|
||||
if (_options.DisablePortRandomization)
|
||||
{
|
||||
appArgs.Add("DcpPublisher:RandomizePorts=false");
|
||||
}
|
||||
|
||||
var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.Prefab_AppHost>(
|
||||
appArgs.ToArray(),
|
||||
configureBuilder: (appOptions, hostSettings) =>
|
||||
{
|
||||
appOptions.DisableDashboard = !_options.EnableAspireDashboard;
|
||||
|
||||
hostSettings ??= new HostApplicationBuilderSettings();
|
||||
hostSettings.Configuration ??= new ConfigurationManager();
|
||||
});
|
||||
|
||||
if (_options.DisablePortRandomization)
|
||||
{
|
||||
builder.Configuration["DcpPublisher:RandomizePorts"] = "false";
|
||||
}
|
||||
builder.Configuration["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true";
|
||||
|
||||
foreach (var project in builder.Resources.OfType<ProjectResource>())
|
||||
{
|
||||
_projects[project.Name] = project;
|
||||
}
|
||||
|
||||
var keep = new HashSet<string>(_infraResourcesToKeep, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Ensure parent resources remain so infra dependencies stay intact.
|
||||
var added = 0;
|
||||
do
|
||||
{
|
||||
added = 0;
|
||||
foreach (var resource in builder.Resources)
|
||||
{
|
||||
if (keep.Contains(resource.Name))
|
||||
{
|
||||
if (resource is IResourceWithParent withParent && withParent.Parent is { } parent && keep.Add(parent.Name))
|
||||
{
|
||||
added++;
|
||||
}
|
||||
|
||||
var relationships = resource.Annotations.OfType<ResourceRelationshipAnnotation>();
|
||||
foreach (var relationship in relationships.Where(r => r.Type == "Parent"))
|
||||
{
|
||||
if (keep.Add(relationship.Resource.Name))
|
||||
{
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (added > 0);
|
||||
|
||||
foreach (var resource in builder.Resources.Where(r => !keep.Contains(r.Name)).ToArray())
|
||||
{
|
||||
builder.Resources.Remove(resource);
|
||||
}
|
||||
|
||||
if (_options.Mode == RunMode.EphemeralIsolated)
|
||||
{
|
||||
foreach (var resource in builder.Resources)
|
||||
{
|
||||
var lifetime = resource.Annotations.OfType<ContainerLifetimeAnnotation>().FirstOrDefault();
|
||||
if (lifetime is not null)
|
||||
{
|
||||
resource.Annotations.Remove(lifetime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_app = await builder.BuildAsync();
|
||||
await _app.StartAsync();
|
||||
|
||||
foreach (var resource in builder.Resources)
|
||||
{
|
||||
await _app.ResourceNotifications.WaitForResourceHealthyAsync(resource.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.DisposeAsync();
|
||||
_app = null;
|
||||
}
|
||||
}
|
||||
|
||||
public ProjectResource GetProject(string name)
|
||||
{
|
||||
if (_projects.TryGetValue(name, out var project))
|
||||
{
|
||||
return project;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Project '{name}' not found in AppHost model.", nameof(name));
|
||||
}
|
||||
|
||||
public async Task<string> GetConnectionStringAsync(string resourceName, CancellationToken cancellationToken = default) =>
|
||||
(await App.GetConnectionStringAsync(resourceName, cancellationToken))
|
||||
?? throw new InvalidOperationException($"Connection string for '{resourceName}' is not available.");
|
||||
|
||||
public static async Task<Dictionary<string, string?>> ResolveConfigForProjectAsync(
|
||||
ProjectResource project,
|
||||
IServiceProvider services,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var config = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (project is not IResourceWithEnvironment withEnvironment ||
|
||||
!withEnvironment.TryGetEnvironmentVariables(out var annotations))
|
||||
{
|
||||
return config;
|
||||
}
|
||||
|
||||
var execOptions = new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
|
||||
{
|
||||
ServiceProvider = services
|
||||
};
|
||||
var execContext = new DistributedApplicationExecutionContext(execOptions);
|
||||
var envContext = new EnvironmentCallbackContext(execContext, cancellationToken: cancellationToken);
|
||||
|
||||
foreach (var annotation in annotations)
|
||||
{
|
||||
await annotation.Callback(envContext);
|
||||
}
|
||||
|
||||
foreach (var (key, value) in envContext.EnvironmentVariables)
|
||||
{
|
||||
if (string.Equals(key, "ASPNETCORE_URLS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolved = value switch
|
||||
{
|
||||
string s => s,
|
||||
IValueProvider provider => await SafeGet(provider, cancellationToken),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (resolved is not null)
|
||||
{
|
||||
config[key.Replace("__", ":")] = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
|
||||
static async Task<string?> SafeGet(IValueProvider provider, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await provider.GetValueAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
Prefab.Tests/Infrastructure/KestrelInProcApp.cs
Normal file
110
Prefab.Tests/Infrastructure/KestrelInProcApp.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts a project entry point in-process with Kestrel so integration tests hit real HTTP.
|
||||
/// </summary>
|
||||
public sealed class KestrelInProcApp<TEntryPoint> : WebApplicationFactory<TEntryPoint>, IAsyncLifetime
|
||||
where TEntryPoint : class
|
||||
{
|
||||
private readonly AspireInfraHost _infra;
|
||||
private readonly ProjectResource _project;
|
||||
private readonly int? _port;
|
||||
private readonly IDictionary<string, string?> _overrides;
|
||||
private readonly Action<IServiceCollection>? _configureServices;
|
||||
private Dictionary<string, string?> _config = new(StringComparer.OrdinalIgnoreCase);
|
||||
private Uri? _httpAddress;
|
||||
|
||||
public KestrelInProcApp(
|
||||
AspireInfraHost infra,
|
||||
ProjectResource project,
|
||||
int? port,
|
||||
IDictionary<string, string?>? overrides = null,
|
||||
Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
_infra = infra;
|
||||
_project = project;
|
||||
_port = port;
|
||||
_overrides = overrides ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
_configureServices = configureServices;
|
||||
|
||||
this.UseKestrel(_port ?? 0);
|
||||
}
|
||||
|
||||
public Uri HttpAddress => _httpAddress ?? throw new InvalidOperationException("Application not started.");
|
||||
public IServiceProvider AppServices => Services;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_config = await AspireInfraHost.ResolveConfigForProjectAsync(_project, _infra.Services);
|
||||
|
||||
foreach (var (key, value) in _overrides)
|
||||
{
|
||||
_config[key.Replace("__", ":")] = value;
|
||||
}
|
||||
|
||||
using var client = CreateClient();
|
||||
_httpAddress = client.BaseAddress ?? ResolveServerAddress();
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
foreach (var (key, value) in _config)
|
||||
{
|
||||
if (string.Equals(key, "ASPNETCORE_URLS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.UseSetting(key, value);
|
||||
}
|
||||
|
||||
builder.UseSetting("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
RemoveHostedService(services, "Prefab.Web.Workers.DataSeeder");
|
||||
RemoveHostedService(services, "Prefab.Catalog.Api.Workers.DataSeeder");
|
||||
_configureServices?.Invoke(services);
|
||||
});
|
||||
}
|
||||
|
||||
public override ValueTask DisposeAsync() => base.DisposeAsync();
|
||||
|
||||
private Uri ResolveServerAddress()
|
||||
{
|
||||
var server = Services.GetRequiredService<IServer>();
|
||||
var feature = server.Features.Get<IServerAddressesFeature>();
|
||||
var address = feature?.Addresses.FirstOrDefault();
|
||||
if (address is not null && Uri.TryCreate(address, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri;
|
||||
}
|
||||
|
||||
// Fallback to localhost with known (or dynamic) port.
|
||||
var port = _port.GetValueOrDefault(0);
|
||||
return new Uri($"http://127.0.0.1:{port}");
|
||||
}
|
||||
|
||||
private static void RemoveHostedService(IServiceCollection services, string typeFullName)
|
||||
{
|
||||
var descriptors = services
|
||||
.Where(s => s.ServiceType == typeof(IHostedService) && s.ImplementationType?.FullName == typeFullName)
|
||||
.ToList();
|
||||
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
206
Prefab.Tests/Infrastructure/PrefabCompositeFixture.cs
Normal file
206
Prefab.Tests/Infrastructure/PrefabCompositeFixture.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Web.Data;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Spins up Aspire infrastructure plus the Prefab web and catalog apps in-process.
|
||||
/// </summary>
|
||||
public class PrefabCompositeFixture : IAsyncLifetime
|
||||
{
|
||||
public const int DebugPrefabPort = 5173;
|
||||
public const int DebugCatalogPort = 5174;
|
||||
|
||||
private readonly PrefabHarnessOptions _options;
|
||||
private readonly TestAuthenticationStateProvider _authenticationProvider = new();
|
||||
|
||||
protected PrefabCompositeFixture(PrefabHarnessOptions? options = null)
|
||||
{
|
||||
_options = options ?? ResolveDefaultOptions();
|
||||
Infra = new AspireInfraHost(_options, "prefab-db");
|
||||
}
|
||||
|
||||
public AspireInfraHost Infra { get; }
|
||||
public KestrelInProcApp<Prefab.Web.Module> PrefabWeb { get; private set; } = default!;
|
||||
public KestrelInProcApp<Prefab.Catalog.Api.Module>? CatalogApi { get; private set; }
|
||||
public TestAuthenticationStateProvider AuthenticationStateProvider => _authenticationProvider;
|
||||
|
||||
public Uri PrefabBaseAddress => PrefabWeb.HttpAddress;
|
||||
public Uri CatalogBaseAddress => CatalogApi?.HttpAddress ?? throw new InvalidOperationException("Catalog API has not been started.");
|
||||
|
||||
protected virtual bool ShouldStartCatalog => true;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await Infra.InitializeAsync();
|
||||
await EnsureDatabaseInitialized(CancellationToken.None);
|
||||
|
||||
var prefabProject = Infra.GetProject("prefab-web");
|
||||
var useFixedPorts = _options.Mode == RunMode.DebugPersistent && _options.DisablePortRandomization;
|
||||
|
||||
Uri? catalogAddress = null;
|
||||
if (ShouldStartCatalog)
|
||||
{
|
||||
var catalogProject = Infra.GetProject("prefab-catalog");
|
||||
var catalogPort = useFixedPorts ? DebugCatalogPort : (int?)null;
|
||||
CatalogApi = new KestrelInProcApp<Prefab.Catalog.Api.Module>(
|
||||
Infra,
|
||||
catalogProject,
|
||||
catalogPort,
|
||||
overrides: null,
|
||||
configureServices: ConfigureTestAuthentication);
|
||||
|
||||
await CatalogApi.InitializeAsync();
|
||||
catalogAddress = CatalogApi.HttpAddress;
|
||||
}
|
||||
else
|
||||
{
|
||||
CatalogApi = null;
|
||||
}
|
||||
|
||||
var overrides = GetWebOverrides(catalogAddress);
|
||||
|
||||
var prefabPort = useFixedPorts ? DebugPrefabPort : (int?)null;
|
||||
PrefabWeb = new KestrelInProcApp<Prefab.Web.Module>(
|
||||
Infra,
|
||||
prefabProject,
|
||||
prefabPort,
|
||||
overrides,
|
||||
ConfigureTestAuthentication);
|
||||
|
||||
await PrefabWeb.InitializeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Infra.DisposeAsync();
|
||||
}
|
||||
|
||||
public HttpClient CreateHttpClientForWeb() => PrefabWeb.CreateClient();
|
||||
|
||||
public IServiceScope CreateWebScope() => PrefabWeb.AppServices.CreateScope();
|
||||
|
||||
public async Task<string> GetConnectionStringAsync(string resourceName, CancellationToken cancellationToken = default) =>
|
||||
await Infra.GetConnectionStringAsync(resourceName, cancellationToken);
|
||||
|
||||
public async Task SeedAsync(Func<AppDb, CancellationToken, Task> seed, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(seed);
|
||||
|
||||
var connectionString = await GetConnectionStringAsync("prefab-db", cancellationToken);
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDb>().UseSqlServer(connectionString);
|
||||
|
||||
await using var db = new AppDb(optionsBuilder.Options, new HandlerContextAccessor());
|
||||
await db.Database.MigrateAsync(cancellationToken);
|
||||
await seed(db, cancellationToken);
|
||||
}
|
||||
|
||||
protected virtual async ValueTask EnsureDatabaseInitialized(CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = await GetConnectionStringAsync("prefab-db", cancellationToken);
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDb>().UseSqlServer(connectionString);
|
||||
|
||||
await using var db = new AppDb(optionsBuilder.Options, new HandlerContextAccessor());
|
||||
await db.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected virtual IDictionary<string, string?> GetWebOverrides(Uri? catalogAddress) =>
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["Prefab__Catalog__Client__Transport"] = "Http",
|
||||
["Prefab__Catalog__Client__BaseAddress"] = catalogAddress?.ToString().TrimEnd('/') ?? string.Empty
|
||||
};
|
||||
|
||||
private void ConfigureTestAuthentication(IServiceCollection services)
|
||||
{
|
||||
var descriptors = services.Where(d => d.ServiceType == typeof(AuthenticationStateProvider)).ToList();
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
services.AddSingleton(_authenticationProvider);
|
||||
services.AddSingleton<AuthenticationStateProvider>(_ => _authenticationProvider);
|
||||
}
|
||||
|
||||
private static PrefabHarnessOptions ResolveDefaultOptions()
|
||||
{
|
||||
var modeEnv = Environment.GetEnvironmentVariable("PREFAB_TEST_MODE")?.Trim().ToLowerInvariant();
|
||||
var mode = modeEnv switch
|
||||
{
|
||||
"debug" => RunMode.DebugPersistent,
|
||||
"ephemeral" => RunMode.EphemeralIsolated,
|
||||
_ => RunMode.DebugPersistent
|
||||
};
|
||||
|
||||
return mode switch
|
||||
{
|
||||
RunMode.EphemeralIsolated => new PrefabHarnessOptions(
|
||||
RunMode.EphemeralIsolated,
|
||||
EnableAspireDashboard: false,
|
||||
DisablePortRandomization: false),
|
||||
_ => new PrefabHarnessOptions(
|
||||
RunMode.DebugPersistent,
|
||||
EnableAspireDashboard: true,
|
||||
DisablePortRandomization: true)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PrefabCompositeFixture_Debug : PrefabCompositeFixture
|
||||
{
|
||||
public PrefabCompositeFixture_Debug()
|
||||
: base(new PrefabHarnessOptions(
|
||||
RunMode.DebugPersistent,
|
||||
EnableAspireDashboard: true,
|
||||
DisablePortRandomization: true))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PrefabCompositeFixture_Ephemeral : PrefabCompositeFixture
|
||||
{
|
||||
public PrefabCompositeFixture_Ephemeral()
|
||||
: base(new PrefabHarnessOptions(
|
||||
RunMode.EphemeralIsolated,
|
||||
EnableAspireDashboard: false,
|
||||
DisablePortRandomization: false))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PrefabCompositeFixture_InProcess : PrefabCompositeFixture
|
||||
{
|
||||
public PrefabCompositeFixture_InProcess()
|
||||
: base(new PrefabHarnessOptions(
|
||||
RunMode.DebugPersistent,
|
||||
EnableAspireDashboard: true,
|
||||
DisablePortRandomization: true))
|
||||
{
|
||||
}
|
||||
|
||||
protected override bool ShouldStartCatalog => false;
|
||||
|
||||
protected override IDictionary<string, string?> GetWebOverrides(Uri? catalogAddress) =>
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["Prefab__Catalog__Client__Transport"] = "InProcess"
|
||||
};
|
||||
}
|
||||
|
||||
[CollectionDefinition("Prefab.Debug")]
|
||||
public sealed class PrefabDebugCollection : ICollectionFixture<PrefabCompositeFixture_Debug>
|
||||
{
|
||||
}
|
||||
|
||||
[CollectionDefinition("Prefab.Ephemeral")]
|
||||
public sealed class PrefabEphemeralCollection : ICollectionFixture<PrefabCompositeFixture_Ephemeral>
|
||||
{
|
||||
}
|
||||
|
||||
[CollectionDefinition("Prefab.InProcess")]
|
||||
public sealed class PrefabInProcessCollection : ICollectionFixture<PrefabCompositeFixture_InProcess>
|
||||
{
|
||||
}
|
||||
12
Prefab.Tests/Infrastructure/PrefabHarnessOptions.cs
Normal file
12
Prefab.Tests/Infrastructure/PrefabHarnessOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the reusable harness with the desired run mode and optional knobs.
|
||||
/// </summary>
|
||||
/// <param name="Mode">The execution mode (debug or ephemeral).</param>
|
||||
/// <param name="EnableAspireDashboard">True to keep the Aspire dashboard enabled.</param>
|
||||
/// <param name="DisablePortRandomization">True to disable randomized ports in Aspire.</param>
|
||||
public sealed record PrefabHarnessOptions(
|
||||
RunMode Mode,
|
||||
bool EnableAspireDashboard = false,
|
||||
bool DisablePortRandomization = false);
|
||||
17
Prefab.Tests/Infrastructure/RunMode.cs
Normal file
17
Prefab.Tests/Infrastructure/RunMode.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the execution mode for the reusable test harness.
|
||||
/// </summary>
|
||||
public enum RunMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixed ports and persistent infrastructure resources; ideal for F5 debugging.
|
||||
/// </summary>
|
||||
DebugPersistent,
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic ports and disposable infrastructure; safe for parallel CI execution.
|
||||
/// </summary>
|
||||
EphemeralIsolated
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Simplified authentication provider for tests. Returns a configurable principal and works outside Blazor circuits.
|
||||
/// </summary>
|
||||
public sealed class TestAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private ClaimsPrincipal _principal = CreateAnonymous();
|
||||
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
ClaimsPrincipal principal;
|
||||
lock (_sync)
|
||||
{
|
||||
principal = _principal;
|
||||
}
|
||||
|
||||
return Task.FromResult(new AuthenticationState(principal));
|
||||
}
|
||||
|
||||
public void SetPrincipal(ClaimsPrincipal? principal)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_principal = principal ?? CreateAnonymous();
|
||||
}
|
||||
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_principal)));
|
||||
}
|
||||
|
||||
public void SetAnonymous() => SetPrincipal(null);
|
||||
|
||||
private static ClaimsPrincipal CreateAnonymous() => new(new ClaimsIdentity());
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Prefab.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Integration.Modules.Catalog.App.Categories;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
public sealed class CategoriesCreateShould(PrefabCompositeFixture_Ephemeral fixture)
|
||||
: IClassFixture<PrefabCompositeFixture_Ephemeral>
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnDomainProblemDetailsWhenNameAlreadyExists()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var client = fixture.CreateHttpClientForWeb();
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-Forwarded-Proto", "https");
|
||||
|
||||
var suffix = Guid.NewGuid().ToString("N")[..6];
|
||||
var name = $"Duplicate Category {suffix}";
|
||||
var description = $"Duplicate description {suffix}";
|
||||
|
||||
var payload = new { name, description };
|
||||
|
||||
var firstResponse = await client.PostAsJsonAsync("/api/catalog/categories", payload, cancellationToken);
|
||||
firstResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var secondResponse = await client.PostAsJsonAsync("/api/catalog/categories", payload, cancellationToken);
|
||||
|
||||
var responseBody = await secondResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
secondResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest, responseBody);
|
||||
|
||||
var problem = JsonSerializer.Deserialize<ProblemDetails?>(responseBody, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
problem.ShouldNotBeNull();
|
||||
problem!.Type.ShouldBe("https://prefab.dev/problems/domain-error");
|
||||
problem.Title.ShouldBe("Request could not be processed.");
|
||||
problem.Detail.ShouldNotBeNull();
|
||||
problem.Detail!.ShouldContain(name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Prefab.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Integration.Modules.Catalog.App.Products;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
public sealed class CatalogProductDetailShould(PrefabCompositeFixture_Ephemeral fixture)
|
||||
: IClassFixture<PrefabCompositeFixture_Ephemeral>
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnNotFoundProblemDetailsForUnknownSlug()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var client = fixture.CreateHttpClientForWeb();
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-Forwarded-Proto", "https");
|
||||
|
||||
var response = await client.GetAsync("/api/catalog/products/does-not-exist", cancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails?>(cancellationToken: cancellationToken);
|
||||
problem.ShouldNotBeNull();
|
||||
problem!.Title.ShouldBe("Resource not found.");
|
||||
problem.Type.ShouldBe("https://prefab.dev/problems/not-found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using Prefab.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Integration.Modules.Catalog.App.Products;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
public sealed class CategoryModelsOverHttpShould(PrefabCompositeFixture_Ephemeral fixture)
|
||||
: IClassFixture<PrefabCompositeFixture_Ephemeral>
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnFromPriceForVariantsAndStandaloneModel()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var categoryName = $"Browse Category {suffix}";
|
||||
var categoryDescription = $"Category description {suffix}";
|
||||
var categorySlug = Slugify(categoryName);
|
||||
|
||||
await fixture.SeedAsync(async (db, token) =>
|
||||
{
|
||||
var checker = new SeedUniqueChecker();
|
||||
|
||||
var category = await Category.Create(categoryName, categoryDescription, checker, token);
|
||||
db.Categories.Add(category);
|
||||
|
||||
var modelWithVariants = await Product.CreateModel(
|
||||
$"Model With Variants {suffix}",
|
||||
$"model-with-variants-{suffix}",
|
||||
"Variants model",
|
||||
checker,
|
||||
token);
|
||||
modelWithVariants.SetBasePrice(15.75m);
|
||||
|
||||
var variantSkuPrefix = $"SKU-{suffix}";
|
||||
var variantA = await Product.CreateVariant(modelWithVariants.Id, $"{variantSkuPrefix}-A", $"Variant A {suffix}", 12.99m, checker, token);
|
||||
var variantB = await Product.CreateVariant(modelWithVariants.Id, $"{variantSkuPrefix}-B", $"Variant B {suffix}", 14.49m, checker, token);
|
||||
|
||||
modelWithVariants.AttachVariant(variantA);
|
||||
modelWithVariants.AttachVariant(variantB);
|
||||
modelWithVariants.AssignToCategory(category.Id, true);
|
||||
|
||||
var modelWithoutVariants = await Product.CreateModel(
|
||||
$"Standalone Model {suffix}",
|
||||
$"standalone-model-{suffix}",
|
||||
"Standalone model",
|
||||
checker,
|
||||
token);
|
||||
modelWithoutVariants.SetBasePrice(9.75m);
|
||||
modelWithoutVariants.AssignToCategory(category.Id, true);
|
||||
|
||||
db.Products.AddRange(modelWithVariants, variantA, variantB, modelWithoutVariants);
|
||||
|
||||
await db.SaveChangesAsync(token);
|
||||
}, cancellationToken);
|
||||
|
||||
var client = fixture.CreateHttpClientForWeb();
|
||||
var httpResponse = await client.GetAsync($"/api/catalog/categories/{categorySlug}/models", cancellationToken);
|
||||
httpResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await httpResponse.Content.ReadFromJsonAsync<GetCategoryModels.Response>(cancellationToken: cancellationToken);
|
||||
payload.ShouldNotBeNull();
|
||||
payload.Result.ShouldNotBeNull();
|
||||
payload.Result.Products.ShouldNotBeNull();
|
||||
|
||||
var variantCard = payload.Result.Products.Single(card => card.Slug == $"model-with-variants-{suffix}");
|
||||
variantCard.FromPrice.ShouldBe(15.75m);
|
||||
|
||||
var standaloneCard = payload.Result.Products.Single(card => card.Slug == $"standalone-model-{suffix}");
|
||||
standaloneCard.FromPrice.ShouldBe(9.75m);
|
||||
}
|
||||
|
||||
private static string Slugify(string value)
|
||||
{
|
||||
var normalized = Regex.Replace(value.Trim().ToLowerInvariant(), "[^a-z0-9]+", "-");
|
||||
return normalized.Trim('-');
|
||||
}
|
||||
|
||||
private sealed class SeedUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
20
Prefab.Tests/Integration/Smoke/RootShould.cs
Normal file
20
Prefab.Tests/Integration/Smoke/RootShould.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Prefab.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Integration.Smoke;
|
||||
|
||||
[Collection("Prefab.Debug")]
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
public sealed class RootShould(PrefabCompositeFixture_Debug fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task RespondWithSuccess()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var client = fixture.CreateHttpClientForWeb();
|
||||
|
||||
using var response = await client.GetAsync("/", cancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
135
Prefab.Tests/Integration/Web/Gateway/CategoriesShould.cs
Normal file
135
Prefab.Tests/Integration/Web/Gateway/CategoriesShould.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Security.Claims;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Tests.Infrastructure;
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
using Prefab.Web.Client.Pages;
|
||||
using Prefab.Web.Client.Services;
|
||||
|
||||
namespace Prefab.Tests.Integration.Web.Gateway;
|
||||
|
||||
//[Collection("Prefab.Debug")]
|
||||
//[Collection("Prefab.Ephemeral")]
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
//public sealed class CategoriesOverHttpShould(PrefabCompositeFixture_Debug fixture)
|
||||
//public sealed class CategoriesOverHttpShould(PrefabCompositeFixture_Ephemeral fixture)
|
||||
public sealed class CategoriesOverHttpShould(PrefabCompositeFixture_Ephemeral fixture)
|
||||
: IClassFixture<PrefabCompositeFixture_Ephemeral>
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Accessories")]
|
||||
[InlineData("Shoes")]
|
||||
public async Task ReturnSeededCategoryRecords(string prefix)
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
|
||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||
var categoryName = $"{prefix}-{suffix}";
|
||||
var categoryDescription = $"{prefix} description {suffix}";
|
||||
|
||||
await fixture.SeedAsync(async (db, token) =>
|
||||
{
|
||||
var checker = new CategoriesTestHelpers.TestUniqueChecker();
|
||||
var category = await Category.Create(categoryName, categoryDescription, checker, token);
|
||||
db.Categories.Add(category);
|
||||
await db.SaveChangesAsync(token);
|
||||
}, cancellationToken);
|
||||
|
||||
var client = fixture.CreateHttpClientForWeb();
|
||||
var pageService = new CategoriesPageService(client);
|
||||
|
||||
var result = await pageService.GetPage(cancellationToken);
|
||||
var model = CategoriesTestHelpers.EnsureSuccess(result);
|
||||
|
||||
Assert.Contains(model.Categories, c => c.Name == categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
//[Collection("Prefab.InProcess")]
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
public sealed class CategoriesInProcessShould(PrefabCompositeFixture_Ephemeral fixture)
|
||||
: IClassFixture<PrefabCompositeFixture_Ephemeral>
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResolveServerImplementationWithoutHttp()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
|
||||
fixture.AuthenticationStateProvider.SetAnonymous();
|
||||
|
||||
using var scope = fixture.CreateWebScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<ICategoriesPageService>();
|
||||
|
||||
var result = await service.GetPage(cancellationToken);
|
||||
var model = CategoriesTestHelpers.EnsureSuccess(result);
|
||||
|
||||
Assert.NotNull(model.Categories);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class CategoriesTestHelpers
|
||||
{
|
||||
public static CategoriesPageModel EnsureSuccess(Result<CategoriesPageModel> result)
|
||||
{
|
||||
if (!result.IsSuccess || result.Value is null)
|
||||
{
|
||||
var detail = result.Problem?.Detail ?? "Categories page request failed.";
|
||||
throw new InvalidOperationException(detail);
|
||||
}
|
||||
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
public sealed class TestUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
[Collection("Prefab.InProcess")]
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
public sealed class CategoriesAuthExample(PrefabCompositeFixture_InProcess fixture)
|
||||
{
|
||||
[Fact(Skip = "Documentation example for simulated authenticated user. Enable when wiring real auth scenarios.")]
|
||||
public async Task Example_UsingAuthenticatedPrincipal()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
|
||||
var principal = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, "user-123"),
|
||||
new Claim(ClaimTypes.Name, "Integration Tester"),
|
||||
new Claim(ClaimTypes.Role, "Catalog.Editor")
|
||||
], "TestHarness"));
|
||||
|
||||
fixture.AuthenticationStateProvider.SetPrincipal(principal);
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = fixture.CreateWebScope();
|
||||
var service = scope.ServiceProvider.GetRequiredService<ICategoriesPageService>();
|
||||
|
||||
var result = await service.GetPage(cancellationToken);
|
||||
var model = CategoriesTestHelpers.EnsureSuccess(result);
|
||||
|
||||
Assert.NotNull(model.Categories);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fixture.AuthenticationStateProvider.SetAnonymous();
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Prefab.Tests/Integration/Web/Nav/NavMenuShould.cs
Normal file
25
Prefab.Tests/Integration/Web/Nav/NavMenuShould.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Net.Http.Json;
|
||||
using Prefab.Tests.Infrastructure;
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Integration.Web.Nav;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
public sealed class NavMenuShould(PrefabCompositeFixture_Ephemeral fixture)
|
||||
: IClassFixture<PrefabCompositeFixture_Ephemeral>
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnNavigationColumnsFromBff()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var client = fixture.CreateHttpClientForWeb();
|
||||
|
||||
var result = await client.GetFromJsonAsync<Result<NavMenuModel>>("/bff/nav-menu/2", cancellationToken);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.IsSuccess.ShouldBeTrue(result.Problem?.Detail);
|
||||
result.Value.ShouldNotBeNull();
|
||||
result.Value!.Columns.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
169
Prefab.Tests/Playwright/ProductListingPlaywrightShould.cs
Normal file
169
Prefab.Tests/Playwright/ProductListingPlaywrightShould.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using Microsoft.Playwright;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.EndToEnd;
|
||||
|
||||
[Collection("Prefab.Ephemeral")]
|
||||
public sealed class ProductListingPlaywrightShould(PrefabCompositeFixture_Ephemeral fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task NavigateViaMegaMenuToLeafCategoryListing()
|
||||
{
|
||||
using var scope = fixture.CreateWebScope();
|
||||
|
||||
var seeder = scope.ServiceProvider.GetRequiredService<Seeder>();
|
||||
await seeder.Execute(scope.ServiceProvider, TestContext.Current.CancellationToken);
|
||||
|
||||
var catalogDb = scope.ServiceProvider.GetRequiredService<IModuleDbReadOnly>();
|
||||
var expectedSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ceiling-supports",
|
||||
"boxes-and-covers",
|
||||
"prefab-assemblies",
|
||||
"rod-strut-hardware"
|
||||
};
|
||||
|
||||
var catalogReady = false;
|
||||
var maxAttempts = 120; // ~60 seconds total with 500ms delay
|
||||
for (var attempt = 1; attempt <= maxAttempts && !catalogReady; attempt++)
|
||||
{
|
||||
var slugs = await catalogDb.Categories
|
||||
.Where(category => category.Slug != null && expectedSlugs.Contains(category.Slug))
|
||||
.Select(category => category.Slug!)
|
||||
.ToListAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
if (slugs.Count == expectedSlugs.Count)
|
||||
{
|
||||
catalogReady = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempt % 20 == 0)
|
||||
{
|
||||
Console.WriteLine($"[Playwright] catalog readiness attempt {attempt}: found [{string.Join(", ", slugs)}]");
|
||||
}
|
||||
|
||||
await Task.Delay(500, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
catalogReady.ShouldBeTrue("Catalog seed should populate root categories before UI navigation.");
|
||||
|
||||
using var playwright = await Playwright.CreateAsync();
|
||||
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
|
||||
|
||||
var context = await browser.NewContextAsync(new BrowserNewContextOptions
|
||||
{
|
||||
BaseURL = fixture.PrefabBaseAddress.ToString().TrimEnd('/')
|
||||
});
|
||||
|
||||
var page = await context.NewPageAsync();
|
||||
|
||||
await page.GotoAsync("/");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
await page.WaitForFunctionAsync("window.Blazor && window.Blazor._internal && window.Blazor._internal.navigationManager");
|
||||
|
||||
var menuItem = page.Locator("li.main-nav__item--with--menu").First;
|
||||
await menuItem.WaitForAsync(new LocatorWaitForOptions
|
||||
{
|
||||
State = WaitForSelectorState.Visible,
|
||||
Timeout = 60_000
|
||||
});
|
||||
var interceptDebug = await menuItem.EvaluateAsync<string?>(
|
||||
@"element => {
|
||||
if (!element) { return null; }
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
const stack = document.elementsFromPoint(x, y);
|
||||
return stack.map(node => `${node.tagName.toLowerCase()}#${node.id}.${node.className}`).join(' > ');
|
||||
}");
|
||||
Console.WriteLine($"[Playwright] elementsFromPoint at nav center: {interceptDebug}");
|
||||
await menuItem.HoverAsync();
|
||||
var menuPanel = page.Locator("#productsMenuPanel");
|
||||
await menuPanel.WaitForAsync(new LocatorWaitForOptions
|
||||
{
|
||||
State = WaitForSelectorState.Attached,
|
||||
Timeout = 60_000
|
||||
});
|
||||
var boundingBox = await menuItem.BoundingBoxAsync();
|
||||
if (boundingBox is null)
|
||||
{
|
||||
throw new InvalidOperationException("Navigation menu item bounding box could not be determined.");
|
||||
}
|
||||
|
||||
var menuReady = false;
|
||||
for (var attempt = 1; attempt <= 10 && !menuReady; attempt++)
|
||||
{
|
||||
var responseTask = page.WaitForResponseAsync(
|
||||
resp => resp.Url.Contains("/bff/nav-menu/"),
|
||||
new PageWaitForResponseOptions { Timeout = 5000 });
|
||||
|
||||
await page.Mouse.MoveAsync(
|
||||
boundingBox.X + boundingBox.Width / 2,
|
||||
boundingBox.Y + boundingBox.Height / 2);
|
||||
await menuItem.DispatchEventAsync("mouseenter");
|
||||
await page.WaitForTimeoutAsync(350);
|
||||
|
||||
IResponse? navResponse = null;
|
||||
try
|
||||
{
|
||||
navResponse = await responseTask;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// No navigation menu response observed within the window; proceed to inspect DOM.
|
||||
}
|
||||
|
||||
menuReady = await page.EvaluateAsync<bool>(
|
||||
"selector => document.querySelectorAll(selector).length > 0",
|
||||
"#productsMenuPanel .megamenu__links--root li");
|
||||
|
||||
if (!menuReady)
|
||||
{
|
||||
var panelText = await menuPanel.InnerTextAsync();
|
||||
Console.WriteLine($"[Playwright] nav menu attempt {attempt} panel text: {panelText}");
|
||||
if (navResponse is not null)
|
||||
{
|
||||
var payload = await navResponse.TextAsync();
|
||||
Console.WriteLine($"[Playwright] nav menu attempt {attempt} response: {payload}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[Playwright] nav menu attempt {attempt} response: timed out waiting for /bff/nav-menu.");
|
||||
}
|
||||
|
||||
await menuItem.DispatchEventAsync("mouseleave");
|
||||
await page.WaitForTimeoutAsync(900);
|
||||
}
|
||||
}
|
||||
|
||||
menuReady.ShouldBeTrue("Mega menu should load category links within retry window.");
|
||||
|
||||
var ceilingLink = menuPanel
|
||||
.GetByRole(AriaRole.Link, new() { Name = "Ceiling Supports" })
|
||||
.First;
|
||||
|
||||
await Microsoft.Playwright.Assertions.Expect(ceilingLink)
|
||||
.ToBeVisibleAsync(new LocatorAssertionsToBeVisibleOptions { Timeout = 60000 });
|
||||
await Microsoft.Playwright.Assertions.Expect(menuPanel)
|
||||
.ToHaveAttributeAsync("aria-hidden", "false");
|
||||
await ceilingLink.ClickAsync();
|
||||
|
||||
await page.WaitForURLAsync("**/catalog/products?category-slug=ceiling-supports");
|
||||
await page.WaitForSelectorAsync(".products-view .product-card");
|
||||
|
||||
var productNamesLocator = page.Locator(".product-card__name");
|
||||
await Microsoft.Playwright.Assertions.Expect(productNamesLocator).ToHaveCountAsync(2);
|
||||
|
||||
var productNames = (await productNamesLocator.AllInnerTextsAsync())
|
||||
.Select(name => name.Trim())
|
||||
.ToList();
|
||||
productNames.ShouldContain("Ceiling T-Bar Box Assembly");
|
||||
productNames.ShouldContain("Fan Hanger Assembly");
|
||||
|
||||
await context.CloseAsync();
|
||||
}
|
||||
}
|
||||
59
Prefab.Tests/Prefab.Tests.csproj
Normal file
59
Prefab.Tests/Prefab.Tests.csproj
Normal file
@@ -0,0 +1,59 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
|
||||
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
|
||||
<XunitConfigFilename>testconfig.json</XunitConfigFilename>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" />
|
||||
<PackageReference Include="Aspire.Hosting.Testing" Version="9.5.1" />
|
||||
<PackageReference Include="Aspire.Hosting.SqlServer" Version="9.5.1" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="xunit.v3" Version="3.1.0" />
|
||||
<PackageReference Include="bunit" Version="2.0.62-preview" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0-rc.2.25502.107" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="System.Net" />
|
||||
<Using Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<Using Include="Aspire.Hosting.ApplicationModel" />
|
||||
<Using Include="Aspire.Hosting.Testing" />
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Prefab.AppHost\Prefab.AppHost.csproj" />
|
||||
<ProjectReference Include="..\Prefab\Prefab.csproj" />
|
||||
<ProjectReference Include="..\Prefab.Shared\Prefab.Shared.csproj" />
|
||||
<ProjectReference Include="..\Prefab.Web\Prefab.Web.csproj" />
|
||||
<ProjectReference Include="..\Prefab.Web.Client\Prefab.Web.Client.csproj" />
|
||||
<ProjectReference Include="..\Prefab.Catalog.Api\Prefab.Catalog.Api.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="testconfig.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="Unit\Modules\" />
|
||||
<Folder Include="Unit\Modules\Catalog\Domain\Entities\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
146
Prefab.Tests/Unit/Catalog/CatalogSeederShould.cs
Normal file
146
Prefab.Tests/Unit/Catalog/CatalogSeederShould.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Prefab.Catalog.Api.Data;
|
||||
using Prefab.Catalog.Data;
|
||||
using Prefab.Catalog.Data.Services;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Data;
|
||||
using Prefab.Handler;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Catalog;
|
||||
|
||||
public sealed class CatalogSeederShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task PopulateCatalogDataInSqliteDatabase()
|
||||
{
|
||||
await using var connection = new SqliteConnection("DataSource=:memory:");
|
||||
await connection.OpenAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IHandlerContextAccessor, HandlerContextAccessor>();
|
||||
|
||||
services.AddDbContext<TestCatalogDb>(options =>
|
||||
{
|
||||
options.UseSqlite(connection);
|
||||
});
|
||||
|
||||
services.AddDbContextFactory<TestCatalogDb>(options =>
|
||||
{
|
||||
options.UseSqlite(connection);
|
||||
}, ServiceLifetime.Scoped);
|
||||
|
||||
services.AddScoped<AppDb>(sp => sp.GetRequiredService<TestCatalogDb>());
|
||||
services.AddScoped<IPrefabDb>(sp => sp.GetRequiredService<TestCatalogDb>());
|
||||
services.AddScoped<IModuleDb>(sp => sp.GetRequiredService<TestCatalogDb>());
|
||||
services.AddScoped<IModuleDbReadOnly>(sp => sp.GetRequiredService<TestCatalogDb>());
|
||||
services.AddScoped<ICatalogDbContextFactory, TestCatalogDbFactory>();
|
||||
services.AddScoped<IUniqueChecker, UniqueChecker>();
|
||||
services.AddSingleton<ILogger<Seeder>>(_ => NullLogger<Seeder>.Instance);
|
||||
services.AddScoped<Seeder>();
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<TestCatalogDb>();
|
||||
await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var seeder = scope.ServiceProvider.GetRequiredService<Seeder>();
|
||||
await seeder.Execute(scope.ServiceProvider, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<TestCatalogDb>();
|
||||
|
||||
var allCategories = await db.Categories
|
||||
.Where(c => c.DeletedOn == null)
|
||||
.ToListAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
allCategories.ShouldNotBeEmpty("Catalog seeder should create categories.");
|
||||
|
||||
var categories = allCategories
|
||||
.Select(c => c.Slug)
|
||||
.Where(slug => !string.IsNullOrWhiteSpace(slug))
|
||||
.Select(slug => slug!.Trim())
|
||||
.ToList();
|
||||
|
||||
var expectedSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ceiling-supports",
|
||||
"boxes-and-covers",
|
||||
"prefab-assemblies",
|
||||
"rod-strut-hardware"
|
||||
};
|
||||
|
||||
categories.ShouldBeSubsetOf(expectedSlugs, "Seeded catalog should only include the expected root categories.");
|
||||
categories.Count.ShouldBe(expectedSlugs.Count, "Seeded catalog should include all expected root categories.");
|
||||
|
||||
var products = await db.Products
|
||||
.Where(p => p.DeletedOn == null && p.Kind == ProductKind.Model)
|
||||
.Include(p => p.Variants)
|
||||
.Select(p => new { p.Slug, p.Name, VariantCount = p.Variants.Count })
|
||||
.ToListAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
products.ShouldNotBeEmpty("Catalog seeder should introduce product models for listing.");
|
||||
products.Any(p => string.Equals(p.Slug, "ceiling-tbar-box-assembly", StringComparison.OrdinalIgnoreCase)).ShouldBeTrue();
|
||||
products.Any(p => string.Equals(p.Slug, "fan-hanger-assembly", StringComparison.OrdinalIgnoreCase)).ShouldBeTrue();
|
||||
products.Any(p => p.VariantCount > 0).ShouldBeTrue("Seed should include at least one model with variants.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestCatalogDb : AppDb
|
||||
{
|
||||
public TestCatalogDb(DbContextOptions<TestCatalogDb> options, IHandlerContextAccessor accessor)
|
||||
: base(options, accessor)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void PrefabOnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.PrefabOnModelCreating(builder);
|
||||
|
||||
foreach (var entity in builder.Model.GetEntityTypes())
|
||||
{
|
||||
var rowVersionProperty = entity.FindProperty("RowVersion");
|
||||
if (rowVersionProperty is not null)
|
||||
{
|
||||
rowVersionProperty.IsNullable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries().Where(e => e.State == EntityState.Added))
|
||||
{
|
||||
var rowVersion = entry.Properties.FirstOrDefault(p => string.Equals(p.Metadata.Name, "RowVersion", StringComparison.Ordinal));
|
||||
if (rowVersion is not null && rowVersion.CurrentValue is null)
|
||||
{
|
||||
rowVersion.CurrentValue = Guid.NewGuid().ToByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestCatalogDbFactory(IDbContextFactory<TestCatalogDb> dbFactory) : ICatalogDbContextFactory
|
||||
{
|
||||
public async ValueTask<IModuleDb> CreateWritableAsync(CancellationToken cancellationToken = default) =>
|
||||
await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
public async ValueTask<IModuleDbReadOnly> CreateReadOnlyAsync(CancellationToken cancellationToken = default) =>
|
||||
await dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using Prefab.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.App.Products;
|
||||
|
||||
[Collection("Prefab.Debug")]
|
||||
[Trait(TraitName.Category, TraitCategory.Integration)]
|
||||
public sealed class CatalogProductDetailNotFoundShould(PrefabCompositeFixture_Debug fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnNotFoundProblemDetailsForUnknownSlug()
|
||||
{
|
||||
var client = fixture.CreateHttpClientForWeb();
|
||||
|
||||
using var response = await client.GetAsync("/api/catalog/products/does-not-exist", TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
using var json = JsonDocument.Parse(payload);
|
||||
json.RootElement.GetProperty("title").GetString().ShouldNotBeNull().ToLowerInvariant().ShouldContain("not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Reflection;
|
||||
using FluentValidation;
|
||||
using Prefab.Catalog.App.Products;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Handler.Decorators;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.App.Products;
|
||||
|
||||
public sealed class QuotePriceShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task RejectRequestsWithoutLookupIdentifiers()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IValidator<QuotePrice.Request>>(_ => CreateValidator());
|
||||
services.AddScoped<IHandler<QuotePrice.Request, QuotePrice.Response>, StubHandler>();
|
||||
services.AddSingleton<IHandlerDecorator, ValidationDecorator>();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var invoker = new HandlerInvoker(provider.GetServices<IHandlerDecorator>(), provider);
|
||||
|
||||
var request = new QuotePrice.Request(null, null, Array.Empty<QuotePrice.Selection>());
|
||||
|
||||
var act = () => invoker.Execute<QuotePrice.Request, QuotePrice.Response>(request, TestContext.Current.CancellationToken);
|
||||
|
||||
var exception = await act.ShouldThrowAsync<ValidationException>();
|
||||
exception.Errors.ShouldContain(failure => failure.ErrorMessage == "Either productId or sku must be provided.");
|
||||
}
|
||||
|
||||
private sealed class StubHandler : IHandler<QuotePrice.Request, QuotePrice.Response>
|
||||
{
|
||||
public Task<QuotePrice.Response> Execute(QuotePrice.Request request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new QuotePrice.Response(0m, Array.Empty<QuotePrice.QuoteBreakdown>()));
|
||||
}
|
||||
|
||||
private static IValidator<QuotePrice.Request> CreateValidator()
|
||||
{
|
||||
var validatorType = typeof(QuotePrice).GetNestedType("Validator", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
validatorType.ShouldNotBeNull();
|
||||
|
||||
return (IValidator<QuotePrice.Request>)Activator.CreateInstance(validatorType!)!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Catalog.Api.Data;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Handler;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.Data;
|
||||
|
||||
public sealed class ProductConcurrencyShould : IAsyncLifetime, IDisposable
|
||||
{
|
||||
private readonly HandlerContextAccessor _accessor = new();
|
||||
private readonly DbContextOptions<AppDb> _options;
|
||||
|
||||
public ProductConcurrencyShould()
|
||||
{
|
||||
var databaseName = Guid.NewGuid().ToString();
|
||||
_options = new DbContextOptionsBuilder<AppDb>()
|
||||
.UseInMemoryDatabase(databaseName)
|
||||
.Options;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThrowWhenSavingWithStaleRowVersion()
|
||||
{
|
||||
var checker = new PermissiveUniqueChecker();
|
||||
|
||||
await using (var seedContext = CreateContext())
|
||||
{
|
||||
var product = await Product.CreateModel("Demo Model", "demo-model", "Demo", checker, CancellationToken.None);
|
||||
product.SetBasePrice(10m);
|
||||
product.RowVersion = Guid.NewGuid().ToByteArray();
|
||||
|
||||
seedContext.Products.Add(product);
|
||||
await seedContext.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var writerOne = CreateContext();
|
||||
await using var writerTwo = CreateContext();
|
||||
|
||||
var first = await writerOne.Products.FirstAsync(CancellationToken.None);
|
||||
var second = await writerTwo.Products.FirstAsync(CancellationToken.None);
|
||||
|
||||
first.SetBasePrice(12m);
|
||||
writerOne.Entry(first).Property(p => p.RowVersion).CurrentValue = Guid.NewGuid().ToByteArray();
|
||||
await writerOne.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
second.SetBasePrice(14m);
|
||||
|
||||
await Should.ThrowAsync<DbUpdateConcurrencyException>(
|
||||
() => writerTwo.SaveChangesAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private AppDb CreateContext() => new(_options, _accessor);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
private sealed class PermissiveUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Events;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.Domain.Events;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public class ProductEventsShould
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductCreatedWhenCreatingModel()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
|
||||
var product = await Product.CreateModel("Model A", "model-a", "desc", uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductCreated>();
|
||||
evt.ProductId.ShouldBe(product.Id);
|
||||
evt.Kind.ShouldBe("Model");
|
||||
evt.Name.ShouldBe("Model A");
|
||||
evt.Slug.ShouldBe("model-a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductVariantCreatedWhenCreatingVariant()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var parent = await Product.CreateModel("Parent", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var variant = await Product.CreateVariant(parent.Id, "SKU-001", "Variant", 12.5m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = variant.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductVariantCreated>();
|
||||
evt.ProductId.ShouldBe(variant.Id);
|
||||
evt.ParentProductId.ShouldBe(parent.Id);
|
||||
evt.Sku.ShouldBe("SKU-001");
|
||||
evt.Price.ShouldBe(12.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductPriceChangedWhenSettingBasePrice()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model B", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
product.ClearEvents();
|
||||
|
||||
product.SetBasePrice(19.99m);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductPriceChanged>();
|
||||
evt.OldPrice.ShouldBeNull();
|
||||
evt.NewPrice.ShouldBe(19.99m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductRenamedWhenRenaming()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model C", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
product.ClearEvents();
|
||||
|
||||
await product.Rename("Model C v2", checker, Ct);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductRenamed>();
|
||||
evt.OldName.ShouldBe("Model C");
|
||||
evt.NewName.ShouldBe("Model C v2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnforceUniqueModelNamesWhenRenaming()
|
||||
{
|
||||
var checker = new ConfigurableUniqueChecker();
|
||||
checker.ModelNameUnique = _ => true;
|
||||
|
||||
var existing = await Product.CreateModel("A", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
existing.ShouldNotBeNull();
|
||||
|
||||
var target = await Product.CreateModel("Second", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
target.ClearEvents();
|
||||
|
||||
checker.ModelNameUnique = name => !string.Equals(name, "A", StringComparison.Ordinal);
|
||||
|
||||
var duplicate = await Should.ThrowAsync<Exception>(() => target.Rename("A", checker, Ct));
|
||||
duplicate.GetType().Name.ShouldBe("DuplicateNameException");
|
||||
|
||||
checker.ModelNameUnique = _ => true;
|
||||
|
||||
await target.Rename("B", checker, Ct);
|
||||
|
||||
target.Name.ShouldBe("B");
|
||||
var evt = target.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductRenamed>();
|
||||
evt.OldName.ShouldBe("Second");
|
||||
evt.NewName.ShouldBe("B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductVariantAttachedWhenAttachingVariant()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var parent = await Product.CreateModel("Parent", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
parent.ClearEvents();
|
||||
var variant = await Product.CreateVariant(parent.Id, "SKU-002", "Variant A", 15m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
variant.ClearEvents();
|
||||
|
||||
parent.AttachVariant(variant);
|
||||
|
||||
var evt = parent.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductVariantAttached>();
|
||||
evt.ParentProductId.ShouldBe(parent.Id);
|
||||
evt.VariantProductId.ShouldBe(variant.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductAttributeValueUpsertedWhenUpsertingSpec()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Specs", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
product.ClearEvents();
|
||||
var attribute = AttributeDefinition.Create("Depth", AttributeDataType.Number);
|
||||
attribute.ClearEvents();
|
||||
|
||||
product.UpsertSpec(attribute.Id, "12", 12m, "in", null);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductAttributeValueUpserted>();
|
||||
evt.ProductId.ShouldBe(product.Id);
|
||||
evt.AttributeDefinitionId.ShouldBe(attribute.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductCategoryAssignedWhenAssigningCategory()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Cat", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
product.ClearEvents();
|
||||
var categoryId = Guid.NewGuid();
|
||||
|
||||
product.AssignToCategory(categoryId, true);
|
||||
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductCategoryAssigned>();
|
||||
evt.ProductId.ShouldBe(product.Id);
|
||||
evt.CategoryId.ShouldBe(categoryId);
|
||||
evt.IsPrimary.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitProductCategoryUnassignedWhenUnassigningCategory()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Cat", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var categoryId = Guid.NewGuid();
|
||||
product.AssignToCategory(categoryId, false);
|
||||
product.ClearEvents();
|
||||
|
||||
var removed = product.UnassignFromCategory(categoryId);
|
||||
|
||||
removed.ShouldBeTrue();
|
||||
var evt = product.Events.ShouldHaveSingleItem().ShouldBeOfType<ProductCategoryUnassigned>();
|
||||
evt.CategoryId.ShouldBe(categoryId);
|
||||
}
|
||||
|
||||
private sealed class ConfigurableUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Func<string, bool> ModelNameUnique { get; set; } = _ => true;
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ModelNameUnique(name));
|
||||
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public class OptionDefinitionEventsShould
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionDefinitionCreatedForChoice()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var option = await OptionDefinition.CreateChoice(product, "color", "Color", checker, isVariantAxis: false, cancellationToken: Ct);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionDefinitionCreated>();
|
||||
evt.ProductId.ShouldBe(product.Id);
|
||||
evt.OptionDefinitionId.ShouldBe(option.Id);
|
||||
evt.DataType.ShouldBe("Choice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionDefinitionCreatedForNumber()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var option = await OptionDefinition.CreateNumber(product, "lead", "Lead Length", "ft", 1, 100, 1, 0.5m, isVariantAxis: true, uniqueChecker: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionDefinitionCreated>();
|
||||
evt.DataType.ShouldBe("Number");
|
||||
evt.IsVariantAxis.ShouldBeTrue();
|
||||
option.IsVariantAxis.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionValueAddedWhenAddingValue()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateChoice(product, "finish", "Finish", checker, isVariantAxis: false, cancellationToken: Ct);
|
||||
option.ClearEvents();
|
||||
|
||||
var value = option.AddValue("ivory", "Ivory", 0.5m, PriceDeltaKind.Absolute);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionValueAdded>();
|
||||
evt.OptionValueId.ShouldBe(value.Id);
|
||||
evt.PriceDelta.ShouldBe(0.5m);
|
||||
evt.Kind.ShouldBe(PriceDeltaKind.Absolute);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionValueChangedWhenChangingValue()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateChoice(product, "shade", "Shade", checker, isVariantAxis: false, cancellationToken: Ct);
|
||||
var value = option.AddValue("warm", "Warm", 0.2m, PriceDeltaKind.Absolute);
|
||||
option.ClearEvents();
|
||||
|
||||
option.ChangeValue(value.Id, "Warm Glow", 0.3m, PriceDeltaKind.Percent);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionValueChanged>();
|
||||
evt.OptionValueId.ShouldBe(value.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionValueRemovedWhenRemovingValue()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateChoice(product, "texture", "Texture", checker, isVariantAxis: false, cancellationToken: Ct);
|
||||
var value = option.AddValue("smooth", "Smooth", null, PriceDeltaKind.Absolute);
|
||||
option.ClearEvents();
|
||||
|
||||
option.RemoveValue(value.Id);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionValueRemoved>();
|
||||
evt.OptionValueId.ShouldBe(value.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionTierAddedWhenAddingTier()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateNumber(product, "length", "Length", "ft", 0, 100, 1, 0.7m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
option.ClearEvents();
|
||||
|
||||
var tier = option.AddTier(10, 20, 0.6m, 5m);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionTierAdded>();
|
||||
evt.OptionTierId.ShouldBe(tier.Id);
|
||||
evt.FromInclusive.ShouldBe(10);
|
||||
evt.ToInclusive.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionTierChangedWhenChangingTier()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateNumber(product, "height", "Height", "ft", 0, 100, 1, 0.7m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var tier = option.AddTier(0, 10, 0.5m, null);
|
||||
option.ClearEvents();
|
||||
|
||||
option.ChangeTier(tier.Id, 5, 15, 0.55m, 2m);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionTierChanged>();
|
||||
evt.OptionTierId.ShouldBe(tier.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitOptionTierRemovedWhenRemovingTier()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel("Model Options", null, null, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var option = await OptionDefinition.CreateNumber(product, "width", "Width", "ft", 0, 100, 1, 0.7m, uniqueChecker: checker, cancellationToken: Ct);
|
||||
var tier = option.AddTier(0, 5, 0.4m, null);
|
||||
option.ClearEvents();
|
||||
|
||||
option.RemoveTier(tier.Id);
|
||||
|
||||
var evt = option.Events.ShouldHaveSingleItem().ShouldBeOfType<OptionTierRemoved>();
|
||||
evt.OptionTierId.ShouldBe(tier.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public class AttributeDefinitionEventsShould
|
||||
{
|
||||
[Fact]
|
||||
public void EmitAttributeDefinitionCreatedWhenCreatingDefinition()
|
||||
{
|
||||
var attribute = AttributeDefinition.Create("Material", AttributeDataType.Enum);
|
||||
|
||||
var evt = attribute.Events.ShouldHaveSingleItem().ShouldBeOfType<AttributeDefinitionCreated>();
|
||||
evt.AttributeDefinitionId.ShouldBe(attribute.Id);
|
||||
evt.DataType.ShouldBe(AttributeDataType.Enum);
|
||||
}
|
||||
}
|
||||
|
||||
public class CategoryEventsShould
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCategoryCreatedWhenCreatingCategory()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
|
||||
var category = await Category.Create("Lighting", "Lighting fixtures", check: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = category.Events.ShouldHaveSingleItem().ShouldBeOfType<CategoryCreated>();
|
||||
evt.CategoryId.ShouldBe(category.Id);
|
||||
evt.Name.ShouldBe("Lighting");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitCategoryRenamedWhenRenamingCategory()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var category = await Category.Create("Legacy", "Legacy", check: checker, cancellationToken: Ct);
|
||||
category.ClearEvents();
|
||||
|
||||
await category.Rename("Modern", check: checker, cancellationToken: Ct);
|
||||
|
||||
var evt = category.Events.ShouldHaveSingleItem().ShouldBeOfType<CategoryRenamed>();
|
||||
evt.OldName.ShouldBe("Legacy");
|
||||
evt.NewName.ShouldBe("Modern");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StubUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using Prefab.Catalog.Api.Data;
|
||||
using Prefab.Handler;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.Domain.Services;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class PricingServiceShould : IDisposable
|
||||
{
|
||||
private readonly AppDb _writeDb;
|
||||
private readonly AppDbReadOnly _readDb;
|
||||
private readonly PricingService _service;
|
||||
private readonly Product _model;
|
||||
private readonly OptionDefinition _colorOption;
|
||||
private readonly OptionValue _ivoryOptionValue;
|
||||
private readonly OptionDefinition _leadLengthOption;
|
||||
|
||||
public PricingServiceShould()
|
||||
{
|
||||
var databaseName = Guid.NewGuid().ToString();
|
||||
var handlerAccessor = new TestHandlerContextAccessor();
|
||||
|
||||
var writeOptions = new DbContextOptionsBuilder<AppDb>()
|
||||
.UseInMemoryDatabase(databaseName)
|
||||
.Options;
|
||||
var readOptions = new DbContextOptionsBuilder<AppDbReadOnly>()
|
||||
.UseInMemoryDatabase(databaseName)
|
||||
.Options;
|
||||
|
||||
_writeDb = new AppDb(writeOptions, handlerAccessor);
|
||||
_readDb = new AppDbReadOnly(readOptions, handlerAccessor);
|
||||
|
||||
(_model, _colorOption, _ivoryOptionValue, _leadLengthOption) = SeedCatalogAsync().GetAwaiter().GetResult();
|
||||
_service = new PricingService(_readDb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitExpectedPriceForChoiceOption()
|
||||
{
|
||||
var request = new QuotePrice.Request(
|
||||
_model.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(_colorOption.Id, _ivoryOptionValue.Id, null)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(13.49m);
|
||||
response.Breakdown.ShouldContain(b => b.Option == _colorOption.Name && b.Delta == 0.50m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitExpectedPriceForNumericOption()
|
||||
{
|
||||
var request = new QuotePrice.Request(
|
||||
_model.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(_leadLengthOption.Id, null, 18m)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(25.59m);
|
||||
response.Breakdown.ShouldContain(b => b.Option == _leadLengthOption.Name && b.Delta == 12.60m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyTierPricingWhenThresholdReached()
|
||||
{
|
||||
var request = new QuotePrice.Request(
|
||||
_model.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(_leadLengthOption.Id, null, 40m)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(41.99m);
|
||||
response.Breakdown.ShouldContain(b => b.Option == _leadLengthOption.Name && b.Delta == 29.00m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RespectTierBoundaryAtTwentySixFeet()
|
||||
{
|
||||
var belowThreshold = new QuotePrice.Request(
|
||||
_model.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(_leadLengthOption.Id, null, 25m)
|
||||
});
|
||||
|
||||
var aboveThreshold = new QuotePrice.Request(
|
||||
_model.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(_leadLengthOption.Id, null, 26m)
|
||||
});
|
||||
|
||||
var below = await _service.QuoteAsync(belowThreshold, TestContext.Current.CancellationToken);
|
||||
var above = await _service.QuoteAsync(aboveThreshold, TestContext.Current.CancellationToken);
|
||||
|
||||
below.UnitPrice.ShouldBe(30.49m); // base 12.99 + 25 * 0.70
|
||||
above.UnitPrice.ShouldBe(33.59m); // base 12.99 + (26 * 0.60) + 5
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPercentOptionsWithoutCompounding()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var model = await Product.CreateModel($"Percent Model {Guid.NewGuid():N}", $"percent-model-{Guid.NewGuid():N}", null, checker, TestContext.Current.CancellationToken);
|
||||
model.SetBasePrice(100m);
|
||||
|
||||
var markupA = await OptionDefinition.CreateChoice(model, "markup_a", "Markup A", checker, isVariantAxis: false, percentScope: null, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var tenPercent = markupA.AddValue("ten_percent", "Ten Percent", 10m, PriceDeltaKind.Percent);
|
||||
|
||||
var markupB = await OptionDefinition.CreateChoice(model, "markup_b", "Markup B", checker, isVariantAxis: false, percentScope: null, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var twentyPercent = markupB.AddValue("twenty_percent", "Twenty Percent", 20m, PriceDeltaKind.Percent);
|
||||
|
||||
_writeDb.Products.Add(model);
|
||||
await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var request = new QuotePrice.Request(
|
||||
model.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(markupA.Id, tenPercent.Id, null),
|
||||
new(markupB.Id, twentyPercent.Id, null)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(130m);
|
||||
response.Breakdown.ShouldContain(b => b.Option == markupA.Name && b.Delta == 10m);
|
||||
response.Breakdown.ShouldContain(b => b.Option == markupB.Name && b.Delta == 20m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyNumericOnlyPercentAdjustments()
|
||||
{
|
||||
var (model, lengthOption) = await CreateModelWithLeadLengthAsync();
|
||||
|
||||
var checker = new StubUniqueChecker();
|
||||
var wireSize = await OptionDefinition.CreateChoice(
|
||||
model,
|
||||
"wire_size",
|
||||
"Wire Size",
|
||||
checker,
|
||||
isVariantAxis: false,
|
||||
percentScope: PercentScope.NumericOnly,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
var fifteenPercent = wireSize.AddValue("fifteen_percent", "15%", 15m, PriceDeltaKind.Percent);
|
||||
|
||||
_writeDb.Products.Add(model);
|
||||
await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var request = new QuotePrice.Request(
|
||||
model.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(lengthOption.Id, null, 40m),
|
||||
new(wireSize.Id, fifteenPercent.Id, null)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(46.34m);
|
||||
response.Breakdown.ShouldContain(b => b.Option == wireSize.Name && b.Delta == 4.35m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyBasePlusNumericPercentAdjustments()
|
||||
{
|
||||
var (model, lengthOption) = await CreateModelWithLeadLengthAsync();
|
||||
|
||||
var checker = new StubUniqueChecker();
|
||||
var wireSize = await OptionDefinition.CreateChoice(
|
||||
model,
|
||||
"wire_size_plus",
|
||||
"Wire Size",
|
||||
checker,
|
||||
isVariantAxis: false,
|
||||
percentScope: PercentScope.BasePlusNumeric,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
var fifteenPercent = wireSize.AddValue("fifteen_percent_plus", "15%", 15m, PriceDeltaKind.Percent);
|
||||
|
||||
_writeDb.Products.Add(model);
|
||||
await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var request = new QuotePrice.Request(
|
||||
model.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(lengthOption.Id, null, 40m),
|
||||
new(wireSize.Id, fifteenPercent.Id, null)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(48.29m);
|
||||
response.Breakdown.ShouldContain(b => b.Option == wireSize.Name && Math.Abs(b.Delta - 6.30m) < 0.01m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuoteCeilingTBarBoxAssemblyScenario()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel(
|
||||
$"ceiling-tbar-{Guid.NewGuid():N}",
|
||||
$"ceiling-tbar-{Guid.NewGuid():N}",
|
||||
null,
|
||||
checker,
|
||||
TestContext.Current.CancellationToken);
|
||||
product.SetBasePrice(39.00m);
|
||||
|
||||
var blankCover = await OptionDefinition.CreateChoice(product, "blank_cover", "Blank Cover", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var blankYes = blankCover.AddValue("yes", "Yes", 1.25m, PriceDeltaKind.Absolute);
|
||||
|
||||
var groundTail = await OptionDefinition.CreateChoice(product, "ground_tail", "Ground Tail", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var groundYes = groundTail.AddValue("yes", "Yes", 0.75m, PriceDeltaKind.Absolute);
|
||||
|
||||
var boxColor = await OptionDefinition.CreateChoice(product, "box_color", "Box Color", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var redColor = boxColor.AddValue("red", "Red", 0.50m, PriceDeltaKind.Absolute);
|
||||
|
||||
var bracketType = await OptionDefinition.CreateChoice(product, "bracket_type", "Bracket Type", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var adjustableBracket = bracketType.AddValue("adjustable", "Adjustable", 2.00m, PriceDeltaKind.Absolute);
|
||||
|
||||
var dedicatedDropwire = await OptionDefinition.CreateChoice(product, "dedicated_dropwire", "Dedicated Dropwire", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var dropwireYes = dedicatedDropwire.AddValue("yes", "Yes", 3.00m, PriceDeltaKind.Absolute);
|
||||
|
||||
var flexWhip = await OptionDefinition.CreateNumber(
|
||||
product,
|
||||
"flex_whip_length",
|
||||
"Flex Whip Length",
|
||||
"ft",
|
||||
1m,
|
||||
100m,
|
||||
1m,
|
||||
0.85m,
|
||||
checker,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
_writeDb.Products.Add(product);
|
||||
await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var request = new QuotePrice.Request(
|
||||
product.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(blankCover.Id, blankYes.Id, null),
|
||||
new(groundTail.Id, groundYes.Id, null),
|
||||
new(boxColor.Id, redColor.Id, null),
|
||||
new(bracketType.Id, adjustableBracket.Id, null),
|
||||
new(dedicatedDropwire.Id, dropwireYes.Id, null),
|
||||
new(flexWhip.Id, null, 10m)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(55.00m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuoteFanHangerAssemblyScenario()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel(
|
||||
$"fan-hanger-{Guid.NewGuid():N}",
|
||||
$"fan-hanger-{Guid.NewGuid():N}",
|
||||
null,
|
||||
checker,
|
||||
TestContext.Current.CancellationToken);
|
||||
product.SetBasePrice(42.00m);
|
||||
|
||||
var groundTail = await OptionDefinition.CreateChoice(product, "ground_tail", "Ground Tail", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var groundYes = groundTail.AddValue("yes", "Yes", 0.75m, PriceDeltaKind.Absolute);
|
||||
|
||||
var blankCover = await OptionDefinition.CreateChoice(product, "blank_cover", "Blank Cover", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var blankYes = blankCover.AddValue("yes", "Yes", 1.25m, PriceDeltaKind.Absolute);
|
||||
|
||||
var dedicatedDropwire = await OptionDefinition.CreateChoice(product, "dedicated_dropwire", "Dedicated Dropwire", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var dropwireYes = dedicatedDropwire.AddValue("yes", "Yes", 3.00m, PriceDeltaKind.Absolute);
|
||||
|
||||
_writeDb.Products.Add(product);
|
||||
await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var request = new QuotePrice.Request(
|
||||
product.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(groundTail.Id, groundYes.Id, null),
|
||||
new(blankCover.Id, blankYes.Id, null),
|
||||
new(dedicatedDropwire.Id, dropwireYes.Id, null)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(47.00m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuoteRaisedDeviceAssemblyScenario()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel(
|
||||
$"raised-device-{Guid.NewGuid():N}",
|
||||
$"raised-device-{Guid.NewGuid():N}",
|
||||
null,
|
||||
checker,
|
||||
TestContext.Current.CancellationToken);
|
||||
product.SetBasePrice(29.00m);
|
||||
|
||||
var deviceColor = await OptionDefinition.CreateChoice(product, "device_color", "Device Color", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var ivory = deviceColor.AddValue("ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute);
|
||||
|
||||
var grade = await OptionDefinition.CreateChoice(product, "grade", "Grade", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var spec = grade.AddValue("spec", "Spec", 1.00m, PriceDeltaKind.Absolute);
|
||||
|
||||
var boxSize = await OptionDefinition.CreateChoice(product, "box_size", "Box Size", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var largeBox = boxSize.AddValue("4_11_16", "4-11/16\" Square", 0.75m, PriceDeltaKind.Absolute);
|
||||
|
||||
var leadLength = await OptionDefinition.CreateNumber(
|
||||
product,
|
||||
"lead_length",
|
||||
"Lead Length",
|
||||
"ft",
|
||||
1m,
|
||||
100m,
|
||||
1m,
|
||||
0.70m,
|
||||
checker,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
leadLength.AddTier(26m, null, 0.60m, 5.00m);
|
||||
|
||||
var wireSize = await OptionDefinition.CreateChoice(product, "wire_size", "Wire Size", checker, percentScope: PercentScope.NumericOnly, cancellationToken: TestContext.Current.CancellationToken);
|
||||
_ = wireSize.AddValue("awg14", "AWG 14", 0m, PriceDeltaKind.Percent);
|
||||
var awg12 = wireSize.AddValue("awg12", "AWG 12", 15m, PriceDeltaKind.Percent);
|
||||
|
||||
var wagos = await OptionDefinition.CreateChoice(product, "wagos", "Wago Connectors", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var wagosYes = wagos.AddValue("yes", "Yes", 2.00m, PriceDeltaKind.Absolute);
|
||||
|
||||
_writeDb.Products.Add(product);
|
||||
await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var request = new QuotePrice.Request(
|
||||
product.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(deviceColor.Id, ivory.Id, null),
|
||||
new(grade.Id, spec.Id, null),
|
||||
new(boxSize.Id, largeBox.Id, null),
|
||||
new(leadLength.Id, null, 30m),
|
||||
new(wireSize.Id, awg12.Id, null),
|
||||
new(wagos.Id, wagosYes.Id, null)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(59.70m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuotePigtailedDeviceScenario()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var product = await Product.CreateModel(
|
||||
$"pigtailed-device-{Guid.NewGuid():N}",
|
||||
$"pigtailed-device-{Guid.NewGuid():N}",
|
||||
null,
|
||||
checker,
|
||||
TestContext.Current.CancellationToken);
|
||||
product.SetBasePrice(24.00m);
|
||||
|
||||
var deviceType = await OptionDefinition.CreateChoice(product, "device_type", "Device Type", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var gfci15 = deviceType.AddValue("gfci15", "GFCI 15A", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var deviceColor = await OptionDefinition.CreateChoice(product, "device_color", "Device Color", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var ivory = deviceColor.AddValue("ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute);
|
||||
|
||||
var grade = await OptionDefinition.CreateChoice(product, "grade", "Grade", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var spec = grade.AddValue("spec", "Spec", 1.00m, PriceDeltaKind.Absolute);
|
||||
|
||||
var leadLength = await OptionDefinition.CreateNumber(
|
||||
product,
|
||||
"lead_length",
|
||||
"Lead Length",
|
||||
"ft",
|
||||
1m,
|
||||
100m,
|
||||
1m,
|
||||
0.55m,
|
||||
checker,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
leadLength.AddTier(50m, null, 0.45m, 4.00m);
|
||||
|
||||
var wireSize = await OptionDefinition.CreateChoice(product, "wire_size", "Wire Size", checker, percentScope: PercentScope.NumericOnly, cancellationToken: TestContext.Current.CancellationToken);
|
||||
_ = wireSize.AddValue("awg14", "AWG 14", 0m, PriceDeltaKind.Percent);
|
||||
var awg12 = wireSize.AddValue("awg12", "AWG 12", 15m, PriceDeltaKind.Percent);
|
||||
|
||||
var wagos = await OptionDefinition.CreateChoice(product, "wagos", "Wago Connectors", checker, cancellationToken: TestContext.Current.CancellationToken);
|
||||
var wagosYes = wagos.AddValue("yes", "Yes", 2.00m, PriceDeltaKind.Absolute);
|
||||
|
||||
_writeDb.Products.Add(product);
|
||||
await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var request = new QuotePrice.Request(
|
||||
product.Id,
|
||||
null,
|
||||
new List<QuotePrice.Selection>
|
||||
{
|
||||
new(deviceType.Id, gfci15.Id, null),
|
||||
new(deviceColor.Id, ivory.Id, null),
|
||||
new(grade.Id, spec.Id, null),
|
||||
new(leadLength.Id, null, 60m),
|
||||
new(wireSize.Id, awg12.Id, null),
|
||||
new(wagos.Id, wagosYes.Id, null)
|
||||
});
|
||||
|
||||
var response = await _service.QuoteAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.UnitPrice.ShouldBe(63.15m);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_writeDb.Dispose();
|
||||
_readDb.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task<(Product Model, OptionDefinition ColorOption, OptionValue IvoryValue, OptionDefinition LeadLengthOption)> SeedCatalogAsync()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var model = await Product.CreateModel("Prefab ATR Box Light", "prefab-atr-box-light", null, checker, TestContext.Current.CancellationToken);
|
||||
model.SetBasePrice(12.99m);
|
||||
|
||||
var colorOption = await OptionDefinition.CreateChoice(model, "color", "Color", checker, isVariantAxis: false, cancellationToken: TestContext.Current.CancellationToken);
|
||||
_ = colorOption.AddValue("white", "White", 0m, PriceDeltaKind.Absolute);
|
||||
var ivoryValue = colorOption.AddValue("ivory", "Ivory", 0.50m, PriceDeltaKind.Absolute);
|
||||
|
||||
var leadLengthOption = await OptionDefinition.CreateNumber(
|
||||
model,
|
||||
"lead_length",
|
||||
"Lead Length",
|
||||
"ft",
|
||||
1m,
|
||||
100m,
|
||||
1m,
|
||||
0.70m,
|
||||
uniqueChecker: checker,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
leadLengthOption.AddTier(26m, null, 0.60m, 5m);
|
||||
|
||||
_writeDb.Products.Add(model);
|
||||
await _writeDb.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
return (model, colorOption, ivoryValue, leadLengthOption);
|
||||
}
|
||||
|
||||
private async Task<(Product Model, OptionDefinition LeadLengthOption)> CreateModelWithLeadLengthAsync()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var model = await Product.CreateModel($"Lead Model {Guid.NewGuid():N}", $"lead-model-{Guid.NewGuid():N}", null, checker, TestContext.Current.CancellationToken);
|
||||
model.SetBasePrice(12.99m);
|
||||
|
||||
var leadLengthOption = await OptionDefinition.CreateNumber(
|
||||
model,
|
||||
$"lead_length_{Guid.NewGuid():N}",
|
||||
"Lead Length",
|
||||
"ft",
|
||||
1m,
|
||||
100m,
|
||||
1m,
|
||||
0.70m,
|
||||
uniqueChecker: checker,
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
leadLengthOption.AddTier(26m, null, 0.60m, 5m);
|
||||
|
||||
return (model, leadLengthOption);
|
||||
}
|
||||
|
||||
private sealed class TestHandlerContextAccessor : IHandlerContextAccessor
|
||||
{
|
||||
public HandlerContext? Current => null;
|
||||
|
||||
public IDisposable Set(HandlerContext handlerContext) => new Noop();
|
||||
|
||||
private sealed class Noop : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.Domain.Services;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class RuleEvaluatorShould
|
||||
{
|
||||
private readonly StubUniqueChecker _checker = new();
|
||||
|
||||
[Fact]
|
||||
public async Task HideOptionsWhenShowRuleNotSatisfied()
|
||||
{
|
||||
var (model, lengthOption, colorOption, redValue) = await BuildModelAsync();
|
||||
|
||||
var showRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, colorOption.Id, RuleEffect.Show, RuleMode.All);
|
||||
showRule.AddCondition(lengthOption, RuleOperator.GreaterThanOrEqual, rightNumber: 10m);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(lengthOption.Id, null, 5m),
|
||||
new OptionSelection(colorOption.Id, redValue.Id, null)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Selections.ShouldContain(s => s.OptionDefinitionId == lengthOption.Id);
|
||||
result.Selections.ShouldNotContain(s => s.OptionDefinitionId == colorOption.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectSelectionsForDisabledOptions()
|
||||
{
|
||||
var (model, lengthOption, colorOption, redValue) = await BuildModelAsync();
|
||||
|
||||
var enableRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, colorOption.Id, RuleEffect.Enable, RuleMode.All);
|
||||
enableRule.AddCondition(lengthOption, RuleOperator.GreaterThanOrEqual, rightNumber: 10m);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(lengthOption.Id, null, 5m),
|
||||
new OptionSelection(colorOption.Id, redValue.Id, null)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("disabled", StringComparison.OrdinalIgnoreCase));
|
||||
result.Selections.ShouldContain(s => s.OptionDefinitionId == lengthOption.Id);
|
||||
result.Selections.ShouldNotContain(s => s.OptionDefinitionId == colorOption.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureRequiredOptionsAreSelected()
|
||||
{
|
||||
var (model, lengthOption, colorOption, redValue) = await BuildModelAsync();
|
||||
|
||||
var requireRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, colorOption.Id, RuleEffect.Require, RuleMode.All);
|
||||
requireRule.AddCondition(lengthOption, RuleOperator.GreaterThanOrEqual, rightNumber: 10m);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(lengthOption.Id, null, 15m)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HonourValueLevelRequirements()
|
||||
{
|
||||
var (model, lengthOption, colorOption, redValue) = await BuildModelAsync();
|
||||
var blueValue = colorOption.AddValue("blue", "Blue", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var requireRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionValue, redValue.Id, RuleEffect.Require, RuleMode.All);
|
||||
requireRule.AddCondition(lengthOption, RuleOperator.GreaterThanOrEqual, rightNumber: 10m);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(lengthOption.Id, null, 12m),
|
||||
new OptionSelection(colorOption.Id, blueValue.Id, null)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("requires a different value", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HideColorWhenOctagonAndRequireDropwireForAdjustable()
|
||||
{
|
||||
var checker = new StubUniqueChecker();
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var model = await Product.CreateModel(
|
||||
$"rule-hide-{Guid.NewGuid():N}",
|
||||
$"rule-hide-{Guid.NewGuid():N}",
|
||||
description: null,
|
||||
checker,
|
||||
cancellationToken: cancellationToken);
|
||||
model.SetBasePrice(10m);
|
||||
|
||||
var boxShape = await OptionDefinition.CreateChoice(model, "box_shape", "Box Shape", checker, cancellationToken: cancellationToken);
|
||||
var octagon = boxShape.AddValue("octagon", "Octagon", 0m, PriceDeltaKind.Absolute);
|
||||
var square = boxShape.AddValue("square", "Square", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var boxColor = await OptionDefinition.CreateChoice(model, "box_color", "Box Color", checker, cancellationToken: cancellationToken);
|
||||
var red = boxColor.AddValue("red", "Red", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var bracketType = await OptionDefinition.CreateChoice(model, "bracket_type", "Bracket Type", checker, cancellationToken: cancellationToken);
|
||||
var adjustable = bracketType.AddValue("adjustable", "Adjustable", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var dedicatedDropwire = await OptionDefinition.CreateChoice(model, "dedicated_dropwire", "Dedicated Dropwire", checker, cancellationToken: cancellationToken);
|
||||
dedicatedDropwire.AddValue("no", "No", 0m, PriceDeltaKind.Absolute);
|
||||
dedicatedDropwire.AddValue("yes", "Yes", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var showRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, boxColor.Id, RuleEffect.Show, RuleMode.All);
|
||||
showRule.AddCondition(boxShape, RuleOperator.Equal, rightOptionValueId: square.Id);
|
||||
|
||||
var requireRule = OptionRuleSet.Create(model, OptionRuleTargetKind.OptionDefinition, dedicatedDropwire.Id, RuleEffect.Require, RuleMode.All);
|
||||
requireRule.AddCondition(bracketType, RuleOperator.Equal, rightOptionValueId: adjustable.Id);
|
||||
|
||||
var evaluator = new RuleEvaluator();
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(boxShape.Id, octagon.Id, null),
|
||||
new OptionSelection(boxColor.Id, red.Id, null),
|
||||
new OptionSelection(bracketType.Id, adjustable.Id, null)
|
||||
};
|
||||
|
||||
var result = evaluator.Evaluate(model, selections);
|
||||
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Selections.ShouldContain(s => s.OptionDefinitionId == boxShape.Id);
|
||||
result.Selections.ShouldNotContain(s => s.OptionDefinitionId == boxColor.Id);
|
||||
result.Errors.ShouldContain(e => e.Contains("required", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private async Task<(Product Model, OptionDefinition Length, OptionDefinition Color, OptionValue Red)> BuildModelAsync()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
var model = await Product.CreateModel(
|
||||
$"rule-model-{Guid.NewGuid():N}",
|
||||
$"rule-model-{Guid.NewGuid():N}",
|
||||
description: null,
|
||||
_checker,
|
||||
cancellationToken: cancellationToken);
|
||||
model.SetBasePrice(10m);
|
||||
|
||||
var lengthOption = await OptionDefinition.CreateNumber(
|
||||
model,
|
||||
"length",
|
||||
"Length",
|
||||
"ft",
|
||||
0m,
|
||||
100m,
|
||||
1m,
|
||||
0.5m,
|
||||
_checker,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var colorOption = await OptionDefinition.CreateChoice(model, "color", "Color", _checker, cancellationToken: cancellationToken);
|
||||
var redValue = colorOption.AddValue("red", "Red", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
return (model, lengthOption, colorOption, redValue);
|
||||
}
|
||||
|
||||
private sealed class StubUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Modules.Catalog.Domain.Services;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class VariantResolverShould
|
||||
{
|
||||
private readonly StubUniqueChecker _checker = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveVariantFromAxisSelections()
|
||||
{
|
||||
var context = await BuildModelWithVariantsAsync();
|
||||
var resolver = new VariantResolver();
|
||||
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(context.ColorOption.Id, context.RedValue.Id, null),
|
||||
new OptionSelection(context.DepthOption.Id, context.Depth24Value.Id, null)
|
||||
};
|
||||
|
||||
var result = resolver.Resolve(context.Model, selections);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Variant.ShouldBe(context.Red24Variant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnErrorWhenAxisSelectionMissing()
|
||||
{
|
||||
var context = await BuildModelWithVariantsAsync();
|
||||
var resolver = new VariantResolver();
|
||||
|
||||
var selections = new[]
|
||||
{
|
||||
new OptionSelection(context.ColorOption.Id, context.RedValue.Id, null)
|
||||
};
|
||||
|
||||
var result = resolver.Resolve(context.Model, selections);
|
||||
|
||||
result.IsSuccess.ShouldBeFalse();
|
||||
result.Errors.ShouldContain(e => e.Contains("axis", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveBySkuWhenProvided()
|
||||
{
|
||||
var context = await BuildModelWithVariantsAsync();
|
||||
var resolver = new VariantResolver();
|
||||
|
||||
var result = resolver.Resolve(context.Model, Array.Empty<OptionSelection>(), context.Blue34Variant.Sku);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Variant.ShouldBe(context.Blue34Variant);
|
||||
}
|
||||
|
||||
private async Task<ModelContext> BuildModelWithVariantsAsync()
|
||||
{
|
||||
var model = await Product.CreateModel($"variant-model-{Guid.NewGuid():N}", $"variant-model-{Guid.NewGuid():N}", null, _checker);
|
||||
|
||||
var colorOption = await OptionDefinition.CreateChoice(model, "color", "Color", _checker, isVariantAxis: true);
|
||||
var redValue = colorOption.AddValue("red", "Red", 0m, PriceDeltaKind.Absolute);
|
||||
var blueValue = colorOption.AddValue("blue", "Blue", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var depthOption = await OptionDefinition.CreateChoice(model, "depth", "Depth", _checker, isVariantAxis: true);
|
||||
var depth24Value = depthOption.AddValue("depth_24", "24\"", 0m, PriceDeltaKind.Absolute);
|
||||
var depth34Value = depthOption.AddValue("depth_34", "34\"", 0m, PriceDeltaKind.Absolute);
|
||||
|
||||
var red24Variant = await Product.CreateVariant(model.Id, $"SKU-{Guid.NewGuid():N}", "Red 24", 20m, _checker);
|
||||
red24Variant.AxisValues.Add(new VariantAxisValue
|
||||
{
|
||||
ProductVariant = red24Variant,
|
||||
ProductVariantId = red24Variant.Id,
|
||||
OptionDefinition = colorOption,
|
||||
OptionDefinitionId = colorOption.Id,
|
||||
OptionValue = redValue,
|
||||
OptionValueId = redValue.Id
|
||||
});
|
||||
red24Variant.AxisValues.Add(new VariantAxisValue
|
||||
{
|
||||
ProductVariant = red24Variant,
|
||||
ProductVariantId = red24Variant.Id,
|
||||
OptionDefinition = depthOption,
|
||||
OptionDefinitionId = depthOption.Id,
|
||||
OptionValue = depth24Value,
|
||||
OptionValueId = depth24Value.Id
|
||||
});
|
||||
|
||||
var blue34Variant = await Product.CreateVariant(model.Id, $"SKU-{Guid.NewGuid():N}", "Blue 34", 22m, _checker);
|
||||
blue34Variant.AxisValues.Add(new VariantAxisValue
|
||||
{
|
||||
ProductVariant = blue34Variant,
|
||||
ProductVariantId = blue34Variant.Id,
|
||||
OptionDefinition = colorOption,
|
||||
OptionDefinitionId = colorOption.Id,
|
||||
OptionValue = blueValue,
|
||||
OptionValueId = blueValue.Id
|
||||
});
|
||||
blue34Variant.AxisValues.Add(new VariantAxisValue
|
||||
{
|
||||
ProductVariant = blue34Variant,
|
||||
ProductVariantId = blue34Variant.Id,
|
||||
OptionDefinition = depthOption,
|
||||
OptionDefinitionId = depthOption.Id,
|
||||
OptionValue = depth34Value,
|
||||
OptionValueId = depth34Value.Id
|
||||
});
|
||||
|
||||
model.AttachVariant(red24Variant);
|
||||
model.AttachVariant(blue34Variant);
|
||||
|
||||
return new ModelContext(
|
||||
model,
|
||||
colorOption,
|
||||
depthOption,
|
||||
redValue,
|
||||
depth24Value,
|
||||
blue34Variant,
|
||||
red24Variant,
|
||||
depth34Value);
|
||||
}
|
||||
|
||||
private sealed record ModelContext(
|
||||
Product Model,
|
||||
OptionDefinition ColorOption,
|
||||
OptionDefinition DepthOption,
|
||||
OptionValue RedValue,
|
||||
OptionValue Depth24Value,
|
||||
Product Blue34Variant,
|
||||
Product Red24Variant,
|
||||
OptionValue Depth34Value);
|
||||
|
||||
private sealed class StubUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
153
Prefab.Tests/Unit/Web/Gateway/HomeServiceShould.cs
Normal file
153
Prefab.Tests/Unit/Web/Gateway/HomeServiceShould.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using FluentValidation;
|
||||
using Moq;
|
||||
using Prefab.Shared.Catalog;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using Prefab.Tests;
|
||||
using Prefab.Web.Client.Models.Catalog;
|
||||
using Prefab.Web.Client.Models.Home;
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
using Prefab.Web.Gateway;
|
||||
using Shouldly;
|
||||
using UrlPolicy = Prefab.Web.UrlPolicy.UrlPolicy;
|
||||
|
||||
namespace Prefab.Tests.Unit.Web.Gateway;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class HomeServiceShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task AggregateFeaturedProductsAndCategories()
|
||||
{
|
||||
var (service, productClient) = CreateService();
|
||||
|
||||
var featured = CreateNode("Featured Leaf", "featured-leaf", displayOrder: 2, isFeatured: true);
|
||||
var secondary = CreateNode("Secondary Leaf", "secondary-leaf", displayOrder: 1, isFeatured: false);
|
||||
|
||||
productClient
|
||||
.Setup(client => client.GetCategoryTree(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GetCategoryTree.Response(new[] { featured, secondary }));
|
||||
|
||||
productClient
|
||||
.Setup(client => client.GetCategoryModels("featured-leaf", It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateListingResponse("featured-leaf", total: 3, cards: new[]
|
||||
{
|
||||
CreateProductCardDto("Featured 1", "featured-1", 19m),
|
||||
CreateProductCardDto("Duplicate", "duplicate", 21m)
|
||||
}));
|
||||
|
||||
productClient
|
||||
.Setup(client => client.GetCategoryModels("secondary-leaf", It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateListingResponse("secondary-leaf", total: 2, cards: new[]
|
||||
{
|
||||
CreateProductCardDto("Secondary 1", "secondary-1", 15m),
|
||||
CreateProductCardDto("Duplicate", "duplicate", 21m)
|
||||
}));
|
||||
|
||||
var result = await service.Get(CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
var payload = result.Value.ShouldNotBeNull();
|
||||
|
||||
payload.LatestCategories.Count.ShouldBe(2);
|
||||
payload.LatestCategories.Select(category => category.Title).ShouldBe(["Featured Leaf", "Secondary Leaf"]);
|
||||
|
||||
payload.FeaturedProducts.Count.ShouldBe(3);
|
||||
payload.FeaturedProducts.Select(product => product.Url).ShouldBe([
|
||||
UrlPolicy.BrowseProduct("featured-1"),
|
||||
UrlPolicy.BrowseProduct("duplicate"),
|
||||
UrlPolicy.BrowseProduct("secondary-1")
|
||||
]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipCategoriesWithoutProducts()
|
||||
{
|
||||
var (service, productClient) = CreateService();
|
||||
|
||||
var empty = CreateNode("Empty Leaf", "empty-leaf", displayOrder: 0, isFeatured: true);
|
||||
var populated = CreateNode("Populated Leaf", "populated-leaf", displayOrder: 1, isFeatured: false);
|
||||
|
||||
productClient
|
||||
.Setup(client => client.GetCategoryTree(3, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GetCategoryTree.Response(new[] { empty, populated }));
|
||||
|
||||
productClient
|
||||
.Setup(client => client.GetCategoryModels("empty-leaf", It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateListingResponse("empty-leaf", total: 0, cards: Array.Empty<GetCategoryModels.ProductCardDto>()));
|
||||
|
||||
productClient
|
||||
.Setup(client => client.GetCategoryModels("populated-leaf", It.IsAny<int?>(), It.IsAny<int?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(CreateListingResponse("populated-leaf", total: 1, cards: new[]
|
||||
{
|
||||
CreateProductCardDto("Populated", "populated-1", 32m)
|
||||
}));
|
||||
|
||||
var result = await service.Get(CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
var payload = result.Value.ShouldNotBeNull();
|
||||
|
||||
payload.LatestCategories.Count.ShouldBe(1);
|
||||
payload.LatestCategories.Single().Title.ShouldBe("Populated Leaf");
|
||||
payload.FeaturedProducts.Single().Url.ShouldBe(UrlPolicy.BrowseProduct("populated-1"));
|
||||
}
|
||||
|
||||
private static (Home.Service Service, Mock<IProductClient> ProductClient) CreateService()
|
||||
{
|
||||
var moduleClient = new Mock<IModuleClient>();
|
||||
var productClient = new Mock<IProductClient>();
|
||||
moduleClient.SetupGet(client => client.Product).Returns(productClient.Object);
|
||||
|
||||
var listingService = new Products.ListingService(moduleClient.Object);
|
||||
var service = new Home.Service(moduleClient.Object, listingService);
|
||||
|
||||
return (service, productClient);
|
||||
}
|
||||
|
||||
private static GetCategoryModels.CategoryNodeDto CreateNode(string name, string slug, int displayOrder, bool isFeatured) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
name,
|
||||
slug,
|
||||
Description: null,
|
||||
HeroImageUrl: null,
|
||||
Icon: null,
|
||||
DisplayOrder: displayOrder,
|
||||
IsFeatured: isFeatured,
|
||||
IsLeaf: true,
|
||||
Children: Array.Empty<GetCategoryModels.CategoryNodeDto>());
|
||||
|
||||
private static GetCategoryModels.Response CreateListingResponse(
|
||||
string slug,
|
||||
int total,
|
||||
IReadOnlyList<GetCategoryModels.ProductCardDto> cards)
|
||||
{
|
||||
var category = new GetCategoryModels.CategoryDetailDto(
|
||||
Guid.NewGuid(),
|
||||
slug,
|
||||
slug,
|
||||
Description: null,
|
||||
HeroImageUrl: null,
|
||||
Icon: null,
|
||||
IsLeaf: true,
|
||||
Children: Array.Empty<GetCategoryModels.CategoryNodeDto>());
|
||||
|
||||
var response = new GetCategoryModels.CategoryProductsResponse(
|
||||
category,
|
||||
cards,
|
||||
total,
|
||||
Page: 1,
|
||||
PageSize: Math.Max(1, cards.Count));
|
||||
|
||||
return new GetCategoryModels.Response(response);
|
||||
}
|
||||
|
||||
private static GetCategoryModels.ProductCardDto CreateProductCardDto(string name, string slug, decimal price) =>
|
||||
new(
|
||||
Guid.NewGuid(),
|
||||
name,
|
||||
slug,
|
||||
FromPrice: price,
|
||||
PrimaryImageUrl: null,
|
||||
LastModifiedOn: DateTimeOffset.UtcNow);
|
||||
}
|
||||
181
Prefab.Tests/Unit/Web/Gateway/NavMenuServiceShould.cs
Normal file
181
Prefab.Tests/Unit/Web/Gateway/NavMenuServiceShould.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Moq;
|
||||
using Prefab.Shared.Catalog;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using Prefab.Web.Gateway.Shared;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Web.Gateway;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class NavMenuServiceShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnOnlyFeaturedChildrenWhenAvailable()
|
||||
{
|
||||
var moduleClient = new Mock<IModuleClient>();
|
||||
var productClient = new Mock<IProductClient>();
|
||||
moduleClient.SetupGet(c => c.Product).Returns(productClient.Object);
|
||||
|
||||
var featuredChild = CreateNode(
|
||||
name: "Featured",
|
||||
slug: "featured",
|
||||
displayOrder: 2,
|
||||
isFeatured: true,
|
||||
isLeaf: true);
|
||||
|
||||
var secondaryChild = CreateNode(
|
||||
name: "Secondary",
|
||||
slug: "secondary",
|
||||
displayOrder: 1,
|
||||
isFeatured: false,
|
||||
isLeaf: true);
|
||||
|
||||
var root = CreateNode(
|
||||
name: "Root",
|
||||
slug: "root",
|
||||
displayOrder: 0,
|
||||
isFeatured: false,
|
||||
isLeaf: false,
|
||||
children: new[] { secondaryChild, featuredChild });
|
||||
|
||||
productClient
|
||||
.Setup(p => p.GetCategoryTree(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GetCategoryTree.Response(new[] { root }));
|
||||
|
||||
var sut = new NavMenu.Service(moduleClient.Object);
|
||||
|
||||
var result = await sut.Get(2, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
var model = result.Value.ShouldNotBeNull();
|
||||
model.Depth.ShouldBe(2);
|
||||
model.Columns.Count.ShouldBe(1);
|
||||
|
||||
var column = model.Columns.Single();
|
||||
column.Root.Name.ShouldBe("Root");
|
||||
column.Root.Url.ShouldBe("/catalog/category?category-slug=root");
|
||||
column.Items.Count.ShouldBe(1);
|
||||
|
||||
var item = column.Items.Single();
|
||||
item.Name.ShouldBe("Featured");
|
||||
item.Url.ShouldBe("/catalog/products?category-slug=featured");
|
||||
column.HasMore.ShouldBeFalse();
|
||||
column.SeeAllUrl.ShouldBe("/catalog/category?category-slug=root");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FallBackToAllChildrenWhenNoFeatured()
|
||||
{
|
||||
var moduleClient = new Mock<IModuleClient>();
|
||||
var productClient = new Mock<IProductClient>();
|
||||
moduleClient.SetupGet(c => c.Product).Returns(productClient.Object);
|
||||
|
||||
var firstChild = CreateNode(
|
||||
name: "Alpha",
|
||||
slug: "alpha",
|
||||
displayOrder: 3,
|
||||
isFeatured: false,
|
||||
isLeaf: true);
|
||||
var secondChild = CreateNode(
|
||||
name: "Beta",
|
||||
slug: "beta",
|
||||
displayOrder: 1,
|
||||
isFeatured: false,
|
||||
isLeaf: false);
|
||||
var thirdChild = CreateNode(
|
||||
name: "Gamma",
|
||||
slug: "gamma",
|
||||
displayOrder: 2,
|
||||
isFeatured: false,
|
||||
isLeaf: true);
|
||||
|
||||
var root = CreateNode(
|
||||
name: "Root",
|
||||
slug: "root",
|
||||
displayOrder: 0,
|
||||
isFeatured: false,
|
||||
isLeaf: false,
|
||||
children: new[] { firstChild, secondChild, thirdChild });
|
||||
|
||||
productClient
|
||||
.Setup(p => p.GetCategoryTree(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GetCategoryTree.Response(new[] { root }));
|
||||
|
||||
var sut = new NavMenu.Service(moduleClient.Object);
|
||||
|
||||
var result = await sut.Get(1, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
var model = result.Value.ShouldNotBeNull();
|
||||
model.Depth.ShouldBe(1);
|
||||
var names = model.Columns.Single().Items.Select(i => i.Name).ToList();
|
||||
names.ShouldBe(["Beta", "Gamma", "Alpha"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClampDepthBeforeCallingCatalog()
|
||||
{
|
||||
var moduleClient = new Mock<IModuleClient>();
|
||||
var productClient = new Mock<IProductClient>();
|
||||
moduleClient.SetupGet(c => c.Product).Returns(productClient.Object);
|
||||
|
||||
var root = CreateNode("Root", "root", 0, false, false);
|
||||
|
||||
productClient
|
||||
.Setup(p => p.GetCategoryTree(2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GetCategoryTree.Response(new[] { root }));
|
||||
|
||||
var sut = new NavMenu.Service(moduleClient.Object);
|
||||
await sut.Get(10, CancellationToken.None);
|
||||
|
||||
productClient.Verify(p => p.GetCategoryTree(2, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnProblemWhenCatalogValidationFails()
|
||||
{
|
||||
var moduleClient = new Mock<IModuleClient>();
|
||||
var productClient = new Mock<IProductClient>();
|
||||
moduleClient.SetupGet(c => c.Product).Returns(productClient.Object);
|
||||
|
||||
productClient
|
||||
.Setup(p => p.GetCategoryTree(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new ValidationException([new ValidationFailure("Depth", "invalid")]));
|
||||
|
||||
var sut = new NavMenu.Service(moduleClient.Object);
|
||||
|
||||
var result = await sut.Get(-1, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeFalse();
|
||||
result.Value.ShouldBeNull();
|
||||
var problem = result.Problem.ShouldNotBeNull();
|
||||
problem.StatusCode.ShouldBe(400);
|
||||
problem.Detail.ShouldBe("Request did not satisfy validation rules.");
|
||||
problem.Errors.ShouldNotBeNull();
|
||||
problem.Errors!.ShouldContainKey("Depth");
|
||||
problem.Errors["Depth"].ShouldContain("invalid");
|
||||
}
|
||||
|
||||
private static GetCategoryModels.CategoryNodeDto CreateNode(
|
||||
string name,
|
||||
string slug,
|
||||
int displayOrder,
|
||||
bool isFeatured,
|
||||
bool isLeaf,
|
||||
IReadOnlyList<GetCategoryModels.CategoryNodeDto>? children = null)
|
||||
{
|
||||
return new GetCategoryModels.CategoryNodeDto(
|
||||
Guid.NewGuid(),
|
||||
name,
|
||||
slug,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
displayOrder,
|
||||
isFeatured,
|
||||
isLeaf,
|
||||
children ?? []);
|
||||
}
|
||||
}
|
||||
121
Prefab.Tests/Unit/Web/Gateway/ProductListingServiceShould.cs
Normal file
121
Prefab.Tests/Unit/Web/Gateway/ProductListingServiceShould.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Moq;
|
||||
using Prefab.Shared.Catalog;
|
||||
using Prefab.Shared.Catalog.Products;
|
||||
using Prefab.Tests;
|
||||
using Prefab.Web.Client.Models.Catalog;
|
||||
using Prefab.Web.Gateway;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Web.Gateway;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class ProductListingServiceShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task MapPricesAndImagesFromModuleResponse()
|
||||
{
|
||||
var categorySlug = "ceiling-supports";
|
||||
var moduleClient = new Mock<IModuleClient>();
|
||||
var productClient = new Mock<IProductClient>();
|
||||
moduleClient.SetupGet(client => client.Product).Returns(productClient.Object);
|
||||
|
||||
var category = new GetCategoryModels.CategoryDetailDto(
|
||||
Guid.NewGuid(),
|
||||
"Ceiling Supports",
|
||||
categorySlug,
|
||||
Description: "Fixtures for ceiling support assemblies.",
|
||||
HeroImageUrl: "/media/categories/ceiling.jpg",
|
||||
Icon: null,
|
||||
IsLeaf: true,
|
||||
Children: Array.Empty<GetCategoryModels.CategoryNodeDto>());
|
||||
|
||||
var cardId = Guid.NewGuid();
|
||||
var response = new GetCategoryModels.CategoryProductsResponse(
|
||||
category,
|
||||
new[]
|
||||
{
|
||||
new GetCategoryModels.ProductCardDto(
|
||||
cardId,
|
||||
"Fan Hanger Assembly",
|
||||
"fan-hanger-assembly",
|
||||
FromPrice: 129.99m,
|
||||
PrimaryImageUrl: "/media/products/fan-hanger.jpg",
|
||||
LastModifiedOn: DateTimeOffset.UtcNow)
|
||||
},
|
||||
Total: 1,
|
||||
Page: 1,
|
||||
PageSize: 24);
|
||||
|
||||
productClient
|
||||
.Setup(client => client.GetCategoryModels(categorySlug, null, null, null, null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GetCategoryModels.Response(response));
|
||||
|
||||
var service = new Products.ListingService(moduleClient.Object);
|
||||
var result = await service.GetCategoryProducts(categorySlug, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Value.ShouldNotBeNull();
|
||||
|
||||
var model = result.Value;
|
||||
model.Category.Slug.ShouldBe(categorySlug);
|
||||
model.Products.Count.ShouldBe(1);
|
||||
|
||||
var card = model.Products.Single();
|
||||
card.Id.ShouldBe(cardId);
|
||||
card.Title.ShouldBe("Fan Hanger Assembly");
|
||||
card.Slug.ShouldBe("fan-hanger-assembly");
|
||||
card.PrimaryImageUrl.ShouldBe("/media/products/fan-hanger.jpg");
|
||||
card.IsPriced.ShouldBeTrue();
|
||||
card.FromPrice.ShouldNotBeNull();
|
||||
card.FromPrice!.Amount.ShouldBe(129.99m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MarkProductsWithoutPriceAsUnpriced()
|
||||
{
|
||||
var categorySlug = "rod-strut-hardware";
|
||||
var moduleClient = new Mock<IModuleClient>();
|
||||
var productClient = new Mock<IProductClient>();
|
||||
moduleClient.SetupGet(client => client.Product).Returns(productClient.Object);
|
||||
|
||||
var category = new GetCategoryModels.CategoryDetailDto(
|
||||
Guid.NewGuid(),
|
||||
"Rod & Strut Hardware",
|
||||
categorySlug,
|
||||
Description: null,
|
||||
HeroImageUrl: null,
|
||||
Icon: null,
|
||||
IsLeaf: true,
|
||||
Children: Array.Empty<GetCategoryModels.CategoryNodeDto>());
|
||||
|
||||
var response = new GetCategoryModels.CategoryProductsResponse(
|
||||
category,
|
||||
new[]
|
||||
{
|
||||
new GetCategoryModels.ProductCardDto(
|
||||
Guid.NewGuid(),
|
||||
"Unpriced Assembly",
|
||||
"unpriced-assembly",
|
||||
FromPrice: null,
|
||||
PrimaryImageUrl: null,
|
||||
LastModifiedOn: DateTimeOffset.UtcNow)
|
||||
},
|
||||
Total: 1,
|
||||
Page: 1,
|
||||
PageSize: 12);
|
||||
|
||||
productClient
|
||||
.Setup(client => client.GetCategoryModels(categorySlug, null, null, null, null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GetCategoryModels.Response(response));
|
||||
|
||||
var service = new Products.ListingService(moduleClient.Object);
|
||||
var result = await service.GetCategoryProducts(categorySlug, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
result.Value.ShouldNotBeNull();
|
||||
|
||||
var card = result.Value.Products.Single();
|
||||
card.IsPriced.ShouldBeFalse();
|
||||
card.FromPrice.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
118
Prefab.Tests/Unit/Web/Pricing/FromPriceCalculatorShould.cs
Normal file
118
Prefab.Tests/Unit/Web/Pricing/FromPriceCalculatorShould.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using Prefab.Catalog.Domain.Entities;
|
||||
using Prefab.Catalog.Domain.Services;
|
||||
using Prefab.Tests;
|
||||
using Prefab.Web.Pricing;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Web.Pricing;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class FromPriceCalculatorShould
|
||||
{
|
||||
private readonly FromPriceCalculator _calculator = new();
|
||||
private readonly IUniqueChecker _uniqueChecker = new AlwaysUniqueChecker();
|
||||
|
||||
[Fact]
|
||||
public async Task UseModelPriceWhenAvailable()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
|
||||
var product = await Product.CreateModel(
|
||||
name: "Model A",
|
||||
slug: "model-a",
|
||||
description: null,
|
||||
uniqueChecker: _uniqueChecker,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
product.SetBasePrice(199.99m);
|
||||
|
||||
var variant = await Product.CreateVariant(
|
||||
product.Id,
|
||||
sku: "SKU-1",
|
||||
name: "Variant 1",
|
||||
price: 149.99m,
|
||||
uniqueChecker: _uniqueChecker,
|
||||
cancellationToken: cancellationToken);
|
||||
product.AttachVariant(variant);
|
||||
|
||||
var result = _calculator.Compute(product);
|
||||
|
||||
result.IsPriced.ShouldBeTrue();
|
||||
result.Amount.ShouldBe(199.99m);
|
||||
result.Currency.ShouldBe("USD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UseLowestVariantPriceWhenModelPriceMissing()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
|
||||
var product = await Product.CreateModel(
|
||||
name: "Model B",
|
||||
slug: "model-b",
|
||||
description: null,
|
||||
uniqueChecker: _uniqueChecker,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var highVariant = await Product.CreateVariant(
|
||||
product.Id,
|
||||
sku: "HIGH",
|
||||
name: "High Variant",
|
||||
price: 249.50m,
|
||||
uniqueChecker: _uniqueChecker,
|
||||
cancellationToken: cancellationToken);
|
||||
var lowVariant = await Product.CreateVariant(
|
||||
product.Id,
|
||||
sku: "LOW",
|
||||
name: "Low Variant",
|
||||
price: 125.25m,
|
||||
uniqueChecker: _uniqueChecker,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
product.AttachVariant(highVariant);
|
||||
product.AttachVariant(lowVariant);
|
||||
|
||||
var result = _calculator.Compute(product);
|
||||
|
||||
result.IsPriced.ShouldBeTrue();
|
||||
result.Amount.ShouldBe(125.25m);
|
||||
result.Currency.ShouldBe("USD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnUnpricedWhenNoPricesAvailable()
|
||||
{
|
||||
var cancellationToken = TestContext.Current.CancellationToken;
|
||||
|
||||
var product = await Product.CreateModel(
|
||||
name: "Model C",
|
||||
slug: "model-c",
|
||||
description: null,
|
||||
uniqueChecker: _uniqueChecker,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var result = _calculator.Compute(product);
|
||||
|
||||
result.IsPriced.ShouldBeFalse();
|
||||
result.Amount.ShouldBeNull();
|
||||
result.Currency.ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class AlwaysUniqueChecker : IUniqueChecker
|
||||
{
|
||||
public Task<bool> CategoryNameIsUnique(string name, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductModelNameIsUnique(string name, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSlugIsUnique(string? slug, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProductSkuIsUnique(string sku, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(true);
|
||||
|
||||
public Task<bool> OptionCodeIsUniqueForProduct(Guid productId, string code, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
130
Prefab.Tests/Unit/Web/Shared/ExceptionHandlerShould.cs
Normal file
130
Prefab.Tests/Unit/Web/Shared/ExceptionHandlerShould.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Catalog.Domain.Exceptions;
|
||||
using Prefab.Web.Shared;
|
||||
using Shouldly;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Prefab.Tests.Unit.Web.Shared;
|
||||
|
||||
public sealed class ExceptionHandlerShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnBadRequestForValidationFailures()
|
||||
{
|
||||
var failure = new ValidationFailure("productId", "Either productId or sku must be provided.");
|
||||
var exception = new ValidationException(new[] { failure });
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IHandlerContextAccessor, HandlerContextAccessor>();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
context.RequestServices = provider;
|
||||
|
||||
var middleware = new ExceptionHandler(
|
||||
_ => throw exception,
|
||||
NullLogger<ExceptionHandler>.Instance,
|
||||
Options.Create(new JsonOptions()));
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var payload = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var root = document.RootElement;
|
||||
|
||||
var messages = new List<string>();
|
||||
if (root.TryGetProperty("errors", out var errorsElement))
|
||||
{
|
||||
messages.AddRange(
|
||||
errorsElement
|
||||
.EnumerateObject()
|
||||
.SelectMany(property => property.Value.EnumerateArray())
|
||||
.Select(element => element.GetString())
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message))!
|
||||
.Cast<string>());
|
||||
}
|
||||
|
||||
if (messages.Count == 0 && root.TryGetProperty("detail", out var detailElement))
|
||||
{
|
||||
messages.Add(detailElement.GetString() ?? string.Empty);
|
||||
}
|
||||
|
||||
messages.ShouldNotBeEmpty(payload);
|
||||
messages.ShouldContain(message => message.Equals("Either productId or sku must be provided.", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnBadRequestForDomainExceptions()
|
||||
{
|
||||
var exception = new DomainValidationException("Demo domain failure.");
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IHandlerContextAccessor, HandlerContextAccessor>();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
context.RequestServices = provider;
|
||||
|
||||
var middleware = new ExceptionHandler(
|
||||
_ => throw exception,
|
||||
NullLogger<ExceptionHandler>.Instance,
|
||||
Options.Create(new JsonOptions()));
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status400BadRequest);
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var payload = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var detail = document.RootElement.GetProperty("detail").GetString();
|
||||
document.RootElement.GetProperty("type").GetString().ShouldBe("https://prefab.dev/problems/domain-error");
|
||||
detail.ShouldNotBeNull();
|
||||
detail!.ShouldContain("Demo domain failure.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnConflictForConcurrencyExceptions()
|
||||
{
|
||||
var exception = new DbUpdateConcurrencyException("Concurrency conflict");
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IHandlerContextAccessor, HandlerContextAccessor>();
|
||||
using var provider = services.BuildServiceProvider();
|
||||
context.RequestServices = provider;
|
||||
|
||||
var middleware = new ExceptionHandler(
|
||||
_ => throw exception,
|
||||
NullLogger<ExceptionHandler>.Instance,
|
||||
Options.Create(new JsonOptions()));
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
context.Response.StatusCode.ShouldBe(StatusCodes.Status409Conflict);
|
||||
context.Response.Body.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(context.Response.Body);
|
||||
var payload = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
document.RootElement.GetProperty("type").GetString().ShouldBe("https://prefab.dev/problems/concurrency-conflict");
|
||||
}
|
||||
|
||||
}
|
||||
75
Prefab.Tests/Unit/Web/Url/UrlPolicyShould.cs
Normal file
75
Prefab.Tests/Unit/Web/Url/UrlPolicyShould.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Prefab.Tests;
|
||||
using Prefab.Web.UrlPolicy;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Unit.Web.Url;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class UrlPolicyShould
|
||||
{
|
||||
[Fact]
|
||||
public void BuildAndParseCategoryRoundTrip()
|
||||
{
|
||||
var url = UrlPolicy.Category(
|
||||
slug: "ceiling-supports",
|
||||
page: 2,
|
||||
pageSize: 48,
|
||||
sort: "price:desc",
|
||||
view: "list");
|
||||
|
||||
var uri = new Uri("https://prefab.test" + url);
|
||||
|
||||
UrlPolicy.TryParseCategory(uri, out var slug, out var page, out var pageSize, out var sort, out var view)
|
||||
.ShouldBeTrue();
|
||||
|
||||
slug.ShouldBe("ceiling-supports");
|
||||
page.ShouldBe(2);
|
||||
pageSize.ShouldBe(48);
|
||||
sort.ShouldBe("price:desc");
|
||||
view.ShouldBe("list");
|
||||
|
||||
UrlPolicy.Category(slug, page, pageSize, sort, view).ShouldBe(url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseCategoryDefaultsWhenMissingOrInvalid()
|
||||
{
|
||||
var url = UrlPolicy.Category(
|
||||
slug: "boxes-and-covers",
|
||||
page: 0,
|
||||
pageSize: 500,
|
||||
sort: null,
|
||||
view: string.Empty);
|
||||
|
||||
UrlPolicy.TryParseCategory(new Uri(url, UriKind.Relative), out var slug, out var page, out var pageSize, out var sort, out var view)
|
||||
.ShouldBeTrue();
|
||||
|
||||
slug.ShouldBe("boxes-and-covers");
|
||||
page.ShouldBe(UrlPolicy.DefaultPage);
|
||||
pageSize.ShouldBe(UrlPolicy.DefaultPageSize);
|
||||
sort.ShouldBe(UrlPolicy.DefaultSort);
|
||||
view.ShouldBe(UrlPolicy.DefaultView);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAndParseProductsRoundTrip()
|
||||
{
|
||||
var url = UrlPolicy.Products(
|
||||
page: 3,
|
||||
pageSize: 12,
|
||||
sort: "name:desc",
|
||||
view: "grid");
|
||||
|
||||
var uri = new Uri("https://prefab.test" + url);
|
||||
|
||||
UrlPolicy.TryParseProducts(uri, out var page, out var pageSize, out var sort, out var view)
|
||||
.ShouldBeTrue();
|
||||
|
||||
page.ShouldBe(3);
|
||||
pageSize.ShouldBe(12);
|
||||
sort.ShouldBe("name:desc");
|
||||
view.ShouldBe("grid");
|
||||
|
||||
UrlPolicy.Products(page, pageSize, sort, view).ShouldBe(url);
|
||||
}
|
||||
}
|
||||
21
Prefab.Tests/Web.Client/BunitSmokeTests.cs
Normal file
21
Prefab.Tests/Web.Client/BunitSmokeTests.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Bunit;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Web.Client;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class BunitSmokeTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void RenderSimpleMarkup()
|
||||
{
|
||||
var cut = Render(builder =>
|
||||
{
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "bunit-smoke");
|
||||
builder.CloseElement();
|
||||
});
|
||||
|
||||
cut.Find(".bunit-smoke").ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Prefab.Web.Client.Components.Catalog;
|
||||
using Prefab.Web.Client.ViewModels.Catalog;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Web.Client.Components.Catalog;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class CategoryCardShould : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void RenderImageAndTitle()
|
||||
{
|
||||
var model = new CategoryCardModel
|
||||
{
|
||||
Title = "Ceiling Supports",
|
||||
Url = "/catalog/products?category-slug=ceiling-supports",
|
||||
ImageUrl = "/images/categories/ceiling-supports.png",
|
||||
SecondaryText = "2 products"
|
||||
};
|
||||
|
||||
var cut = Render<CategoryCard>(parameters => parameters.Add(p => p.Category, model));
|
||||
|
||||
var root = cut.Find($".{TemplateCss.CardRoot}");
|
||||
root.ClassList.ShouldContain(TemplateCss.CategoryCardRoot);
|
||||
|
||||
var image = cut.Find($".{TemplateCss.CategoryCardImage} img");
|
||||
image.GetAttribute("src").ShouldBe(model.ImageUrl);
|
||||
image.GetAttribute("alt").ShouldBe(model.Title);
|
||||
|
||||
var title = cut.Find($".{TemplateCss.CategoryCardName}");
|
||||
title.TextContent.Trim().ShouldBe(model.Title);
|
||||
|
||||
var secondary = cut.Find($".{TemplateCss.CategoryCardProducts}");
|
||||
secondary.TextContent.Trim().ShouldBe("2 products");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPlaceholderWhenNoImage()
|
||||
{
|
||||
const string placeholder = "No image available";
|
||||
var model = new CategoryCardModel
|
||||
{
|
||||
Title = "Prefabricated Assemblies",
|
||||
Url = "/catalog/products?category-slug=prefab-assemblies"
|
||||
};
|
||||
|
||||
var cut = Render<CategoryCard>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Category, model);
|
||||
parameters.Add(p => p.ImagePlaceholderText, placeholder);
|
||||
});
|
||||
|
||||
var placeholderNode = cut.Find($".{TemplateCss.CategoryCardImage} .{TemplateCss.CategoryCardImagePlaceholder}");
|
||||
placeholderNode.TextContent.Trim().ShouldBe(placeholder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvokeSelectionCallback()
|
||||
{
|
||||
var model = new CategoryCardModel
|
||||
{
|
||||
Title = "Rod & Strut Hardware",
|
||||
Url = "/catalog/products?category-slug=rod-strut-hardware"
|
||||
};
|
||||
|
||||
CategoryCardModel? selected = null;
|
||||
|
||||
var cut = Render<CategoryCard>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Category, model);
|
||||
parameters.Add(p => p.OnCategorySelected, EventCallback.Factory.Create<CategoryCardModel>(this, value => selected = value));
|
||||
});
|
||||
|
||||
cut.Find("a").Click();
|
||||
|
||||
selected.ShouldNotBeNull();
|
||||
ReferenceEquals(selected, model).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
128
Prefab.Tests/Web.Client/Components/Catalog/ProductCardShould.cs
Normal file
128
Prefab.Tests/Web.Client/Components/Catalog/ProductCardShould.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using System.Globalization;
|
||||
using Bunit;
|
||||
using Prefab.Web.Client.Components.Catalog;
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
using Prefab.Web.Client.ViewModels.Catalog;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Web.Client.Components.Catalog;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class ProductCardShould : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void RenderSaleBadgeOldPriceAndRating()
|
||||
{
|
||||
var model = CreateModel(m =>
|
||||
{
|
||||
m.IsOnSale = true;
|
||||
m.OldPrice = 419m;
|
||||
m.Rating = 4;
|
||||
m.ReviewCount = 15;
|
||||
m.Badges.Add("New");
|
||||
});
|
||||
|
||||
var cut = Render<ProductCard>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Product, model);
|
||||
parameters.Add(p => p.ShowPrice, true);
|
||||
});
|
||||
|
||||
var root = cut.Find($".{TemplateCss.ProductCardRoot}");
|
||||
root.ClassList.ShouldContain(TemplateCss.ProductCardGridModifier);
|
||||
|
||||
cut.FindAll($".{TemplateCss.ProductCardBadgeSale}").Count.ShouldBe(1);
|
||||
cut.FindAll($".{TemplateCss.ProductCardBadge}").Count.ShouldBe(2);
|
||||
|
||||
var newPrice = cut.Find($".{TemplateCss.ProductCardPriceNew}");
|
||||
var expectedNewPrice = model.FromPrice?.Amount ?? throw new InvalidOperationException("Expected from price to be set.");
|
||||
newPrice.TextContent.Trim().ShouldBe(expectedNewPrice.ToString("C", CultureInfo.CurrentCulture));
|
||||
|
||||
var oldPrice = cut.Find($".{TemplateCss.ProductCardPriceOld}");
|
||||
var expectedOldPrice = model.OldPrice ?? throw new InvalidOperationException("Expected old price to be set for sale items.");
|
||||
oldPrice.TextContent.Trim().ShouldBe(expectedOldPrice.ToString("C", CultureInfo.CurrentCulture));
|
||||
|
||||
var rating = cut.Find($".{TemplateCss.ProductCardRating}");
|
||||
var ratingLabel = rating.QuerySelector($".{TemplateCss.Rating}")!;
|
||||
ratingLabel.GetAttribute("aria-label").ShouldBe("4 out of 5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotRenderSaleBadgeOrOldPriceWhenAbsent()
|
||||
{
|
||||
var model = CreateModel(m =>
|
||||
{
|
||||
m.IsOnSale = false;
|
||||
m.OldPrice = null;
|
||||
m.Rating = 0;
|
||||
m.ReviewCount = 0;
|
||||
});
|
||||
|
||||
var cut = Render<ProductCard>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Product, model);
|
||||
parameters.Add(p => p.ShowPrice, true);
|
||||
});
|
||||
|
||||
cut.FindAll($".{TemplateCss.ProductCardBadgeSale}").ShouldBeEmpty();
|
||||
cut.Markup.ShouldNotContain(TemplateCss.ProductCardPriceOld);
|
||||
cut.Markup.ShouldNotContain(TemplateCss.ProductCardRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotRenderPricesByDefault()
|
||||
{
|
||||
var model = CreateModel();
|
||||
|
||||
var cut = Render<ProductCard>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Product, model);
|
||||
// ShowPrice is false by default
|
||||
});
|
||||
|
||||
cut.Markup.ShouldNotContain(TemplateCss.ProductCardPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotRenderActionButtons()
|
||||
{
|
||||
var model = CreateModel();
|
||||
|
||||
var cut = Render<ProductCard>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.Product, model);
|
||||
});
|
||||
|
||||
cut.Markup.ShouldNotContain("product-card__actions");
|
||||
cut.Markup.ShouldNotContain("product-card__buttons");
|
||||
}
|
||||
|
||||
private static ProductCardModel CreateModel(Action<ProductCardModel>? configure = null)
|
||||
{
|
||||
var model = new ProductCardModel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Aluminum Chandelier",
|
||||
Url = "/catalog/product/aluminum-chandelier",
|
||||
Slug = "aluminum-chandelier",
|
||||
CategoryName = "Chandeliers",
|
||||
CategoryUrl = "/catalog/chandeliers",
|
||||
PrimaryImageUrl = "/images/products/product1.jpg",
|
||||
FromPrice = new MoneyModel
|
||||
{
|
||||
Amount = 321.54m,
|
||||
Currency = "USD"
|
||||
},
|
||||
IsPriced = true,
|
||||
Rating = 3,
|
||||
ReviewCount = 2,
|
||||
Sku = "SKU-1001",
|
||||
IsOnSale = true,
|
||||
OldPrice = 399.99m,
|
||||
LastModifiedOn = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
configure?.Invoke(model);
|
||||
return model;
|
||||
}
|
||||
}
|
||||
71
Prefab.Tests/Web.Client/Components/Shared/CardGridShould.cs
Normal file
71
Prefab.Tests/Web.Client/Components/Shared/CardGridShould.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Prefab.Web.Client.Components.Shared;
|
||||
using Shouldly;
|
||||
|
||||
namespace Prefab.Tests.Web.Client.Components.Shared;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class CardGridShould : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void RenderGridLayoutWithItems()
|
||||
{
|
||||
var cut = Render<CardGrid>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.View, "grid");
|
||||
parameters.Add(p => p.ChildContent, builder =>
|
||||
{
|
||||
builder.OpenComponent<CardGridItem>(0);
|
||||
builder.AddAttribute(1, "ChildContent", (RenderFragment)(child =>
|
||||
{
|
||||
child.OpenElement(0, "div");
|
||||
child.AddAttribute(1, "class", "fake-card-1");
|
||||
child.CloseElement();
|
||||
}));
|
||||
builder.CloseComponent();
|
||||
|
||||
builder.OpenComponent<CardGridItem>(2);
|
||||
builder.AddAttribute(3, "ChildContent", (RenderFragment)(child =>
|
||||
{
|
||||
child.OpenElement(0, "div");
|
||||
child.AddAttribute(1, "class", "fake-card-2");
|
||||
child.CloseElement();
|
||||
}));
|
||||
builder.CloseComponent();
|
||||
});
|
||||
});
|
||||
|
||||
var container = cut.Find($".{TemplateCss.ProductsViewList}");
|
||||
container.ClassList.ShouldContain(TemplateCss.ProductsList);
|
||||
container.ClassList.ShouldContain(TemplateCss.ProductsListLayoutGrid);
|
||||
|
||||
var items = cut.FindAll($".{TemplateCss.ProductsListItem}");
|
||||
items.Count.ShouldBe(2);
|
||||
items[0].QuerySelector(".fake-card-1").ShouldNotBeNull();
|
||||
items[1].QuerySelector(".fake-card-2").ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderListLayoutModifier()
|
||||
{
|
||||
var cut = Render<CardGrid>(parameters =>
|
||||
{
|
||||
parameters.Add(p => p.View, "list");
|
||||
parameters.Add(p => p.ChildContent, builder =>
|
||||
{
|
||||
builder.OpenComponent<CardGridItem>(0);
|
||||
builder.AddAttribute(1, "ChildContent", (RenderFragment)(child =>
|
||||
{
|
||||
child.OpenElement(0, "div");
|
||||
child.AddAttribute(1, "class", "fake-card");
|
||||
child.CloseElement();
|
||||
}));
|
||||
builder.CloseComponent();
|
||||
});
|
||||
});
|
||||
|
||||
var container = cut.Find($".{TemplateCss.ProductsViewList}");
|
||||
container.ClassList.ShouldContain(TemplateCss.ProductsListLayoutList);
|
||||
}
|
||||
}
|
||||
118
Prefab.Tests/Web.Client/Pages/HomeComponentShould.cs
Normal file
118
Prefab.Tests/Web.Client/Pages/HomeComponentShould.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Linq;
|
||||
using Prefab.Web.Client.Models.Home;
|
||||
using Prefab.Web.Client.Models.Shared;
|
||||
using Prefab.Web.Client.Pages;
|
||||
using Prefab.Web.Client.Services;
|
||||
using Prefab.Web.Client.ViewModels.Catalog;
|
||||
using Shouldly;
|
||||
using Telerik.Blazor.Components;
|
||||
|
||||
namespace Prefab.Tests.Web.Client.Pages;
|
||||
|
||||
[Trait(TraitName.Category, TraitCategory.Unit)]
|
||||
public sealed class HomeComponentShould
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadHomeContentPopulatesProductsAndCategories()
|
||||
{
|
||||
var payload = new HomePageModel
|
||||
{
|
||||
FeaturedProducts = Enumerable.Range(0, 3)
|
||||
.Select(index => new ProductCardModel
|
||||
{
|
||||
Title = $"Product {index}",
|
||||
Url = $"/product/{index}",
|
||||
FromPrice = new MoneyModel
|
||||
{
|
||||
Amount = index,
|
||||
Currency = "USD"
|
||||
},
|
||||
IsPriced = true,
|
||||
Sku = $"SKU-{index}",
|
||||
LastModifiedOn = DateTimeOffset.UtcNow
|
||||
})
|
||||
.ToList(),
|
||||
LatestCategories = new List<CategoryCardModel>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = "Leaf A",
|
||||
Url = "/catalog/category?category-slug=leaf-a",
|
||||
SecondaryText = "2 Products"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var service = new FakeHomePageService(Result<HomePageModel>.Success(payload));
|
||||
var component = new TestableHomeComponent();
|
||||
component.ConfigureServices(service, new NoopNotificationService());
|
||||
|
||||
await component.InitializeAsync();
|
||||
|
||||
component.IsProductsLoading.ShouldBeFalse();
|
||||
component.IsCategoriesLoading.ShouldBeFalse();
|
||||
|
||||
component.FeaturedProductsView.Count.ShouldBe(3);
|
||||
component.CategoriesView.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LeaveCollectionsEmptyWhenServiceReturnsNoData()
|
||||
{
|
||||
var service = new FakeHomePageService(Result<HomePageModel>.Success(new HomePageModel()));
|
||||
var component = new TestableHomeComponent();
|
||||
component.ConfigureServices(service, new NoopNotificationService());
|
||||
|
||||
await component.InitializeAsync();
|
||||
|
||||
component.FeaturedProductsView.ShouldBeEmpty();
|
||||
component.CategoriesView.ShouldBeEmpty();
|
||||
component.HasFeaturedProducts.ShouldBeFalse();
|
||||
component.HasCategories.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private sealed class FakeHomePageService(Result<HomePageModel> result) : IHomePageService
|
||||
{
|
||||
private Result<HomePageModel> Result { get; set; } = result;
|
||||
|
||||
public Task<Result<HomePageModel>> Get(CancellationToken cancellationToken) => Task.FromResult(Result);
|
||||
}
|
||||
|
||||
private sealed class NoopNotificationService : INotificationService
|
||||
{
|
||||
public void Attach(TelerikNotification notification)
|
||||
{
|
||||
}
|
||||
|
||||
public void ShowError(string message)
|
||||
{
|
||||
}
|
||||
|
||||
public void ShowSuccess(string message)
|
||||
{
|
||||
}
|
||||
|
||||
public void ShowWarning(string message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestableHomeComponent : HomeComponent
|
||||
{
|
||||
public void ConfigureServices(IHomePageService homePageService, INotificationService notificationService)
|
||||
{
|
||||
HomePageService = homePageService;
|
||||
NotificationService = notificationService;
|
||||
}
|
||||
|
||||
public Task InitializeAsync() => OnInitializedAsync();
|
||||
|
||||
public IReadOnlyList<ProductCardModel> FeaturedProductsView => base.FeaturedProducts;
|
||||
public IReadOnlyList<CategoryCardModel> CategoriesView => base.LatestCategories;
|
||||
|
||||
public bool IsProductsLoading => IsFeaturedProductsLoading;
|
||||
public bool IsCategoriesLoading => base.AreCategoriesLoading;
|
||||
public bool HasFeaturedProducts => FeaturedProductsHaveItemsToShow;
|
||||
public bool HasCategories => CategoriesHaveItemsToShow;
|
||||
}
|
||||
}
|
||||
55
Prefab.Tests/Web.Client/TemplateCss.cs
Normal file
55
Prefab.Tests/Web.Client/TemplateCss.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace Prefab.Tests.Web.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the class hooks used in the Prefab.Web template assets.
|
||||
/// Sources:
|
||||
/// HTML (product listing/grid & product card): template/html/classic/shop-left-sidebar.html
|
||||
/// HTML (product list view): template/html/classic/shop-list.html
|
||||
/// HTML (category tiles): template/html/classic/index.html
|
||||
/// CSS: template/html/classic/css/style.css
|
||||
/// </summary>
|
||||
public static class TemplateCss
|
||||
{
|
||||
// Product card
|
||||
public const string ProductCardRoot = "product-card";
|
||||
public const string ProductCardGridModifier = "product-card--layout--grid";
|
||||
public const string ProductCardListModifier = "product-card--layout--list";
|
||||
public const string ProductCardActions = "product-card__actions";
|
||||
public const string ProductCardActionsList = "product-card__actions-list";
|
||||
public const string ProductCardImage = "product-card__image";
|
||||
public const string ProductCardBadges = "product-card__badges-list";
|
||||
public const string ProductCardBadge = "product-card__badge";
|
||||
public const string ProductCardBadgeSale = "product-card__badge--style--sale";
|
||||
public const string ProductCardInfo = "product-card__info";
|
||||
public const string ProductCardCategory = "product-card__category";
|
||||
public const string ProductCardName = "product-card__name";
|
||||
public const string ProductCardRating = "product-card__rating";
|
||||
public const string ProductCardRatingTitle = "product-card__rating-title";
|
||||
public const string ProductCardRatingStars = "product-card__rating-stars";
|
||||
public const string Rating = "rating";
|
||||
public const string RatingStar = "rating__star";
|
||||
public const string ProductCardPricesList = "product-card__prices-list";
|
||||
public const string ProductCardPrice = "product-card__price";
|
||||
public const string ProductCardPriceNew = "product-card__price-new";
|
||||
public const string ProductCardPriceOld = "product-card__price-old";
|
||||
public const string ProductCardButtons = "product-card__buttons";
|
||||
public const string ProductCardButtonsList = "product-card__buttons-list";
|
||||
public const string ProductCardAddToCart = "product-card__addtocart";
|
||||
public const string ProductCardWishlist = "product-card__wishlist";
|
||||
public const string ProductCardCompare = "product-card__compare";
|
||||
|
||||
// Category card
|
||||
public const string CardRoot = "card";
|
||||
public const string CategoryCardRoot = "category-card";
|
||||
public const string CategoryCardImage = "category-card__image";
|
||||
public const string CategoryCardImagePlaceholder = "category-card__image-placeholder";
|
||||
public const string CategoryCardName = "category-card__name";
|
||||
public const string CategoryCardProducts = "category-card__products";
|
||||
|
||||
// Listing container
|
||||
public const string ProductsViewList = "products-view__list";
|
||||
public const string ProductsList = "products-list";
|
||||
public const string ProductsListItem = "products-list__item";
|
||||
public const string ProductsListLayoutGrid = "products-list--layout--grid-3";
|
||||
public const string ProductsListLayoutList = "products-list--layout--list";
|
||||
}
|
||||
8
Prefab.Tests/testconfig.json
Normal file
8
Prefab.Tests/testconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/v3/testconfig.schema.json",
|
||||
"parallel": {
|
||||
"assembly": true,
|
||||
"collections": true,
|
||||
"defaultMaxParallelThreads": 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user