diff --git a/docs/features/reporting.md b/docs/features/reporting.md new file mode 100644 index 000000000..41867e1dc --- /dev/null +++ b/docs/features/reporting.md @@ -0,0 +1,44 @@ +# Reporting + +Implementations of this feature registers a singleton `IReportContext` with +which you can read raw data from database. Add this feature using +`AddReporting()` extension; + +```csharp +app.Features.AddReporting(...); +``` + +## Fake + +Adds a fake report context that allows you to return data directly from `.json` +resources. + +```csharp +c => c.Fake(basePath: "Fake") +``` + +## Mock + +Adds a mock instance of report context to be used during spec tests. + +```csharp +c => c.Mock() +``` + +## Native SQL + +Adds a report context instance that uses a `IStatelessSession` instance to +execute native SQL queries read from `.sql` resources in your project. + +```csharp +c => c.NativeSql(basePath: "Queries/MySql") +``` + +> [!TIP] +> +> You may group your RDBMS specific queries in different folders, and use +> setting to specify which folder to use depending on environment. +> +> ```csharp +> c => c.NativeSql(basePath: Settings.Required("Reporting:NativeSql:BasePath")) +> ``` diff --git a/docs/recipes/service.md b/docs/recipes/service.md index 78b12bb11..736f213c1 100644 --- a/docs/recipes/service.md +++ b/docs/recipes/service.md @@ -53,7 +53,7 @@ Bake.New | | With Method | | | Communication | :white_check_mark: HTTP | :white_check_mark: Mock | | Core | :white_check_mark: Dotnet | :white_check_mark: Mock | -| Cors(s) | :white_check_mark: Disabled | :no_entry: | +| Cors | :white_check_mark: Disabled | :no_entry: | | Database | :white_check_mark: Sqlite | :white_check_mark: In Memory | | Exception Handling | :white_check_mark: Default | :white_check_mark: | | Greeting | :white_check_mark: Swagger | :no_entry: | diff --git a/src/core/Baked.Architecture/Testing/Extensions/AssertionExtensions.cs b/src/core/Baked.Architecture/Testing/Extensions/AssertionExtensions.cs index d0621c252..3de4e7a76 100644 --- a/src/core/Baked.Architecture/Testing/Extensions/AssertionExtensions.cs +++ b/src/core/Baked.Architecture/Testing/Extensions/AssertionExtensions.cs @@ -10,7 +10,19 @@ public static class AssertionExtensions public static void ShouldFail(this Spec _, string message = "") => throw new AssertionException(message); + [DoesNotReturn] + public static Task ShouldFailAsync(this Spec _, string message = "") => + throw new AssertionException(message); + [DoesNotReturn] public static void ShouldPass(this Spec _, string message = "") => Assert.Pass(message); + + [DoesNotReturn] + public static Task ShouldPassAsync(this Spec _, string message = "") + { + Assert.Pass(message); + + return Task.CompletedTask; + } } \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/DataAccess/DataAccessLayer.cs b/src/recipe/Baked.Recipe.Service.Application/DataAccess/DataAccessLayer.cs index 645d31600..978aa82cd 100644 --- a/src/recipe/Baked.Recipe.Service.Application/DataAccess/DataAccessLayer.cs +++ b/src/recipe/Baked.Recipe.Service.Application/DataAccess/DataAccessLayer.cs @@ -43,8 +43,6 @@ protected override PhaseContext GetContext(AddServices phase) services.AddSingleton(sp => _fluentConfiguration.BuildConfiguration()); services.AddSingleton(sp => sp.GetRequiredService().BuildSessionFactory()); - services.AddScoped(sp => sp.GetRequiredService().OpenSession()); - services.AddSingleton>(sp => () => sp.UsingCurrentScope().GetRequiredService()); }) .Build(); } diff --git a/src/recipe/Baked.Recipe.Service.Application/Database/InMemory/InMemoryDatabaseFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Database/InMemory/InMemoryDatabaseFeature.cs index bf2e4ebe9..df835b526 100644 --- a/src/recipe/Baked.Recipe.Service.Application/Database/InMemory/InMemoryDatabaseFeature.cs +++ b/src/recipe/Baked.Recipe.Service.Application/Database/InMemory/InMemoryDatabaseFeature.cs @@ -9,7 +9,7 @@ namespace Baked.Database.InMemory; public class InMemoryDatabaseFeature : IFeature { - ISession? _globalSession; + IStatelessSession? _globalSession; public void Configure(LayerConfigurator configurator) { @@ -28,7 +28,7 @@ public void Configure(LayerConfigurator configurator) initializations.AddInitializer(sf => { // In memory db is disposed when last connection is closed, this connection is to keep the db open - _globalSession = sf.OpenSession(); + _globalSession = sf.OpenStatelessSession(); sp.GetRequiredService().ExportSchema(false, true, false, _globalSession.Connection); }); @@ -48,6 +48,5 @@ public void Configure(LayerConfigurator configurator) spec.GiveMe.TheSession().Clear(); }); }); - } } \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Orm/AutoMap/AutoMapOrmFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Orm/AutoMap/AutoMapOrmFeature.cs index e443f6dcd..740241351 100644 --- a/src/recipe/Baked.Recipe.Service.Application/Orm/AutoMap/AutoMapOrmFeature.cs +++ b/src/recipe/Baked.Recipe.Service.Application/Orm/AutoMap/AutoMapOrmFeature.cs @@ -70,6 +70,8 @@ public void Configure(LayerConfigurator configurator) configurator.ConfigureServiceCollection(services => { services.AddFromAssembly(configurator.Context.GetGeneratedAssembly(nameof(AutoMapOrmFeature))); + services.AddScoped(sp => sp.GetRequiredService().OpenSession()); + services.AddSingleton>(sp => () => sp.UsingCurrentScope().GetRequiredService()); services.AddScoped(typeof(IEntityContext<>), typeof(EntityContext<>)); services.AddSingleton(typeof(IQueryContext<>), typeof(QueryContext<>)); }); diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeData.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeData.cs new file mode 100644 index 000000000..b8e0aff5c --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeData.cs @@ -0,0 +1,29 @@ +using System.Text.RegularExpressions; + +namespace Baked.Reporting.Fake; + +public record FakeData( + Dictionary? Parameters, + List> Result +) +{ + public bool Matches(Dictionary parameters) + { + if (Parameters is null) { return true; } + + foreach (var (key, pattern) in Parameters) + { + if (!parameters.ContainsKey(key)) + { + return false; + } + + if (pattern is null) { continue; } + if (Regex.IsMatch($"{parameters[key]}", pattern)) { continue; } + + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingExtensions.cs new file mode 100644 index 000000000..3f6644506 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingExtensions.cs @@ -0,0 +1,16 @@ +using Baked.Reporting; +using Baked.Reporting.Fake; +using Baked.Testing; +using Microsoft.Extensions.FileProviders; + +namespace Baked; + +public static class FakeReportingExtensions +{ + public static FakeReportingFeature Fake(this ReportingConfigurator _) => + new(); + + public static IReportContext AFakeReportContext(this Stubber giveMe, + string basePath = "Fake" + ) => new ReportContext(giveMe.The(), new(basePath)); +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingFeature.cs new file mode 100644 index 000000000..39a22efd8 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/FakeReportingFeature.cs @@ -0,0 +1,15 @@ +using Baked.Architecture; +using Microsoft.Extensions.DependencyInjection; + +namespace Baked.Reporting.Fake; + +public class FakeReportingFeature : IFeature +{ + public void Configure(LayerConfigurator configurator) + { + configurator.ConfigureServiceCollection(services => + { + services.AddSingleton(); + }); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportContext.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportContext.cs new file mode 100644 index 000000000..b12fc7052 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportContext.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.FileProviders; +using Newtonsoft.Json; + +namespace Baked.Reporting.Fake; + +public class ReportContext(IFileProvider _fileProvider, ReportOptions _options) + : IReportContext +{ + public async Task Execute(string queryName, Dictionary parameters) + { + var dataPath = $"/{Path.Join(_options.BasePath, $"{queryName}.json")}"; + if (!_fileProvider.Exists(dataPath)) { throw new QueryNotFoundException(queryName); } + + var dataString = await _fileProvider.ReadAsStringAsync(dataPath) ?? string.Empty; + + var fakes = JsonConvert.DeserializeObject>(dataString) ?? new(); + var match = fakes.FirstOrDefault(fake => fake.Matches(parameters)); + if (match is null) { return []; } + + return match.Result.Select(row => row.Values.ToArray()).ToArray(); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportOptions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportOptions.cs new file mode 100644 index 000000000..97b6f7ce2 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Fake/ReportOptions.cs @@ -0,0 +1,5 @@ +using Baked.Runtime; + +namespace Baked.Reporting.Fake; + +public record ReportOptions(Setting BasePath); \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingExtensions.cs new file mode 100644 index 000000000..3e563f99a --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingExtensions.cs @@ -0,0 +1,10 @@ +using Baked.Reporting; +using Baked.Reporting.Mock; + +namespace Baked; + +public static class MockReportingExtensions +{ + public static MockReportingFeature Mock(this ReportingConfigurator _) => + new(); +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingFeature.cs new file mode 100644 index 000000000..547a8f153 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/Mock/MockReportingFeature.cs @@ -0,0 +1,14 @@ +using Baked.Architecture; + +namespace Baked.Reporting.Mock; + +public class MockReportingFeature : IFeature +{ + public void Configure(LayerConfigurator configurator) + { + configurator.ConfigureTestConfiguration(test => + { + test.Mocks.Add(singleton: true); + }); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs new file mode 100644 index 000000000..a05db08d0 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingExtensions.cs @@ -0,0 +1,12 @@ +using Baked.Reporting; +using Baked.Reporting.NativeSql; +using Baked.Runtime; + +namespace Baked; + +public static class NativeSqlReportingExtensions +{ + public static NativeSqlReportingFeature NativeSql(this ReportingConfigurator _, + Setting? basePath = default + ) => new(basePath ?? string.Empty); +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs new file mode 100644 index 000000000..349eac829 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/NativeSqlReportingFeature.cs @@ -0,0 +1,35 @@ +using Baked.Architecture; +using Baked.Runtime; +using Microsoft.Extensions.DependencyInjection; +using NHibernate; + +namespace Baked.Reporting.NativeSql; + +public class NativeSqlReportingFeature(Setting _basePath) + : IFeature +{ + public void Configure(LayerConfigurator configurator) + { + configurator.ConfigureConfigurationBuilder(configuration => + { + configuration.AddJsonAsDefault($$""" + { + "Logging": { + "LogLevel": { + "NHibernate": "None", + "NHibernate.Sql": "{{(configurator.IsDevelopment() ? "Debug" : "None")}}" + } + } + } + """); + }); + + configurator.ConfigureServiceCollection(services => + { + services.AddSingleton(new ReportOptions(_basePath)); + services.AddSingleton(); + services.AddScoped(sp => sp.GetRequiredService().OpenStatelessSession()); + services.AddSingleton>(sp => () => sp.UsingCurrentScope().GetRequiredService()); + }); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs new file mode 100644 index 000000000..9acfb2c53 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportContext.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.FileProviders; + +namespace Baked.Reporting.NativeSql; + +public class ReportContext(IFileProvider _fileProvider, Func _getStatelessSession, ReportOptions _options) + : IReportContext +{ + public async Task Execute(string queryName, Dictionary parameters) + { + var queryPath = $"/{Path.Join(_options.BasePath, $"{queryName}.sql")}"; + if (!_fileProvider.Exists(queryPath)) + { + throw new QueryNotFoundException(queryName); + } + + var queryString = await _fileProvider.ReadAsStringAsync(queryPath); + var query = _getStatelessSession().CreateSQLQuery(queryString); + foreach (var (name, value) in parameters) + { + query.SetParameter(name, value); + } + + var result = await query.ListAsync(); + + return result.Cast().ToArray(); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportOptions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportOptions.cs new file mode 100644 index 000000000..fc2be9728 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/NativeSql/ReportOptions.cs @@ -0,0 +1,5 @@ +using Baked.Runtime; + +namespace Baked.Reporting.NativeSql; + +public record ReportOptions(Setting BasePath); \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingConfigurator.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingConfigurator.cs new file mode 100644 index 000000000..59055014a --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingConfigurator.cs @@ -0,0 +1,3 @@ +namespace Baked.Reporting; + +public class ReportingConfigurator { } \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs new file mode 100644 index 000000000..8f45193ca --- /dev/null +++ b/src/recipe/Baked.Recipe.Service.Application/Reporting/ReportingExtensions.cs @@ -0,0 +1,48 @@ +using Baked.Architecture; +using Baked.Reporting; +using Baked.Testing; +using Moq; + +namespace Baked; + +public static class ReportingExtensions +{ + public static void AddReporting(this List features, Func> configure) => + features.Add(configure(new())); + + public static IReportContext TheReportContext(this Mocker mockMe, + object?[][]? data = default + ) + { + data ??= []; + + var result = Mock.Get(mockMe.Spec.GiveMe.The()); + + if (data is not null) + { + result + .Setup(df => df.Execute(It.IsAny(), It.IsAny>())) + .ReturnsAsync(data); + } + + return result.Object; + } + + public static void VerifyExecute(this IReportContext dataFetcher, + string? queryName = default, + (string key, object value)? parameter = default, + List<(string key, object value)>? parameters = default + ) + { + parameters ??= parameter is not null ? [parameter.Value] : []; + + Mock.Get(dataFetcher).Verify( + df => df.Execute( + It.Is(q => queryName == null || q == queryName), + It.Is>(p => + parameters.All((kvp) => p.ContainsKey(kvp.key) && Equals(p[kvp.key], kvp.value)) + ) + ) + ); + } +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs b/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs new file mode 100644 index 000000000..382455838 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service/Reporting/IReportContext.cs @@ -0,0 +1,6 @@ +namespace Baked.Reporting; + +public interface IReportContext +{ + Task Execute(string queryName, Dictionary parameters); +} \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service/Reporting/QueryNotFoundException.cs b/src/recipe/Baked.Recipe.Service/Reporting/QueryNotFoundException.cs new file mode 100644 index 000000000..f755bd529 --- /dev/null +++ b/src/recipe/Baked.Recipe.Service/Reporting/QueryNotFoundException.cs @@ -0,0 +1,4 @@ +namespace Baked.Reporting; + +public class QueryNotFoundException(string queryName) + : Exception($"No query file with '{queryName}' was found"); \ No newline at end of file diff --git a/src/recipe/Baked.Recipe.Service/Runtime/FileProviderExtensions.cs b/src/recipe/Baked.Recipe.Service/Runtime/FileProviderExtensions.cs index f68f00a01..f74e9be4e 100644 --- a/src/recipe/Baked.Recipe.Service/Runtime/FileProviderExtensions.cs +++ b/src/recipe/Baked.Recipe.Service/Runtime/FileProviderExtensions.cs @@ -4,23 +4,26 @@ namespace Microsoft.Extensions.FileProviders; public static class FileProviderExtensions { - public static string? ReadAsString(this IFileProvider provider, string subPath) + public static bool Exists(this IFileProvider provider, string subpath) => + provider.GetFileInfo(subpath).Exists; + + public static string? ReadAsString(this IFileProvider provider, string subpath) { - using var streamReader = provider.CreateStreamReader(subPath); + using var streamReader = provider.CreateStreamReader(subpath); return streamReader.ReadToEnd(); } - public static async Task ReadAsStringAsync(this IFileProvider provider, string subPath) + public static async Task ReadAsStringAsync(this IFileProvider provider, string subpath) { - using var streamReader = provider.CreateStreamReader(subPath); + using var streamReader = provider.CreateStreamReader(subpath); return await streamReader.ReadToEndAsync(); } - static StreamReader CreateStreamReader(this IFileProvider provider, string subPath) + static StreamReader CreateStreamReader(this IFileProvider provider, string subpath) { - var fileInfo = provider.GetFileInfo(subPath); + var fileInfo = provider.GetFileInfo(subpath); if (!fileInfo.Exists) { return StreamReader.Null; } return new(fileInfo.CreateReadStream(), Encoding.UTF8); diff --git a/test/recipe/Baked.Test.Recipe.Service.Application/Dockerfile b/test/recipe/Baked.Test.Recipe.Service.Application/Dockerfile index b338f4548..2297b2cb9 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Application/Dockerfile +++ b/test/recipe/Baked.Test.Recipe.Service.Application/Dockerfile @@ -10,7 +10,7 @@ ENV ASPNETCORE_URLS=http://+:80 FROM mcr.microsoft.com/dotnet/sdk:8.0 AS publish -COPY --exclude=**/*.cs --exclude=**/*.json --exclude=**/*.md --exclude=**/*.yml --exclude=Makefile . . +COPY --exclude=**/*.cs --exclude=**/*.sql --exclude=**/*.json --exclude=**/*.md --exclude=**/*.yml --exclude=Makefile . . RUN dotnet restore COPY . . diff --git a/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs b/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs index 7f443784d..ce6967682 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Application/Program.cs @@ -28,14 +28,21 @@ claims: ["User", "Admin", "BaseA", "BaseB", "GivenA", "GivenB", "GivenC"], baseClaims: ["BaseA", "BaseB"] ), - core: c => c.Dotnet(baseNamespace: _ => "Baked.Test") + core: c => c + .Dotnet(baseNamespace: _ => "Baked.Test") .ForNfr(c.Dotnet(entryAssembly: Assembly.GetExecutingAssembly(), baseNamespace: _ => "Baked.Test")), cors: c => c.AspNetCore(Settings.Required("CorsOrigin")), database: c => c - .PostgreSql() - .ForDevelopment(c.Sqlite()) - .ForNfr(c.Sqlite(fileName: $"Baked.Test.Recipe.Service.Nfr.db")), - exceptionHandling: ex => ex.Default(typeUrlFormat: "https://baked.mouseless.codes/errors/{0}"), - configure: app => app.Features.AddConfigurationOverrider() + .Sqlite() + .ForProduction(c.PostgreSql()), + exceptionHandling: c => c.Default(typeUrlFormat: "https://baked.mouseless.codes/errors/{0}"), + configure: app => + { + app.Features.AddReporting(c => c + .NativeSql(basePath: "Reporting/Sqlite") + .ForProduction(c.NativeSql(basePath: "Reporting/PostgreSql")) + ); + app.Features.AddConfigurationOverrider(); + } ) .Run(); \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Application/appsettings.Nfr.json b/test/recipe/Baked.Test.Recipe.Service.Application/appsettings.Nfr.json new file mode 100644 index 000000000..7162117ea --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service.Application/appsettings.Nfr.json @@ -0,0 +1,7 @@ +{ + "Database": { + "Sqlite": { + "FileName": "Baked.Test.Recipe.Service.Nfr.db" + } + } +} diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs new file mode 100644 index 000000000..bafea8d90 --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FakingReportResults.cs @@ -0,0 +1,80 @@ +using Baked.Reporting; + +namespace Baked.Test.Reporting; + +/// +/// Fake data is in `Baked.Test.Recipe.Service/Reporting/Fake/entity.json`. +/// Look the data in this json file to understand why fake report context +/// returns below expected results. +/// +public class FakingReportResults : TestServiceSpec +{ + [Test] + public async Task Loads_fake_data_from_resource_using_configured_base_path() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "string", "test" } }); + + result.Length.ShouldBePositive(); + } + + [Test] + public void When_report_was_not_found_throws_query_not_found_exception() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var action = context.Execute("non-existing", []); + + action.ShouldThrow().Message.ShouldContain("non-existing"); + } + + [Test] + public async Task Clears_keys_from_rows__returning_just_cells() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "string", "test" } }); + + result[0][0].ShouldDeeplyBe(2); + result[0][1].ShouldDeeplyBe("test 1"); + result[1][0].ShouldDeeplyBe(1); + result[1][1].ShouldDeeplyBe("test 2"); + } + + [Test] + public async Task Uses_argument_values_to_find_different_fake_data_for_the_same_report() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "string", "filtered" } }); + + result[0][0].ShouldDeeplyBe(4); + result[0][1].ShouldDeeplyBe("filtered 1"); + result[1][0].ShouldDeeplyBe(3); + result[1][1].ShouldDeeplyBe("filtered 2"); + } + + [Test] + public async Task Argument_matchers_uses_regex_patterns_to_allow_wildcard_like_expressions() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "string", "reg" } }); + + result[0][0].ShouldDeeplyBe(6); + result[0][1].ShouldDeeplyBe("reg-x"); + result[1][0].ShouldDeeplyBe(5); + result[1][1].ShouldDeeplyBe("reg-y"); + } + + [Test] + public async Task When_no_data_matches_argument_returns_empty_array() + { + var context = GiveMe.AFakeReportContext(basePath: "Reporting/Fake"); + + var result = await context.Execute("entity", new() { { "string", "non-existing" } }); + + result.ShouldBeEmpty(); + } +} \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs new file mode 100644 index 000000000..ce6dfdb20 --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/FetchingReportUsingNativeSql.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; +using System.Net; +using System.Net.Http.Json; + +namespace Baked.Test.Runtime; + +public class FetchingReportUsingNativeSql : TestServiceNfr +{ + [Test] + public async Task Loads_query_from_resource_and_fetches_data_from_db() + { + await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-1" })); + await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-1" })); + await Client.PostAsync("/entities", JsonContent.Create(new { @string = "test-2" })); + + var response = await Client.GetAsync("report-samples/entity?string=test"); + dynamic? content = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + + ((int?)content?[0].count).ShouldBe(2); + ((string?)content?[0].@string).ShouldBe("test-1"); + ((int?)content?[1].count).ShouldBe(1); + ((string?)content?[1].@string).ShouldBe("test-2"); + } + + [Test] + public async Task Throws_query_not_found_when_attempts_to_execute_a_non_existing_query() + { + var response = await Client.GetAsync("report-samples/non-existing"); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + } +} \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs new file mode 100644 index 000000000..0ec8d234e --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Reporting/MockingReportResults.cs @@ -0,0 +1,44 @@ +using Baked.Reporting; + +namespace Baked.Test.Reporting; + +public class MockingReportResults : TestServiceSpec +{ + [Test] + public void Mock_report_context_is_provided_during_tests() + { + var context = GiveMe.The(); + + var getMock = () => Mock.Get(context); + + getMock.ShouldNotThrow(); + } + + [Test] + public async Task Sets_up_mock_report_to_return_desired_data() + { + MockMe.TheReportContext(data: [[2, "test-1"], [1, "test-2"]]); + var reportSamples = GiveMe.The(); + + var result = await reportSamples.GetEntity("test"); + + result[0].Count.ShouldBe(2); + result[0].String.ShouldBe("test-1"); + result[1].Count.ShouldBe(1); + result[1].String.ShouldBe("test-2"); + } + + [Test] + public async Task Verifies_execute_with_given_query_and_parameters() + { + var reportContext = MockMe.TheReportContext(); + var reportSamples = GiveMe.The(); + + await reportSamples.GetEntity("test"); + + reportContext.VerifyExecute( + queryName: "entity", + parameter: ("string", "test%") + ); + } +} \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/Runtime/ReadingResources.cs b/test/recipe/Baked.Test.Recipe.Service.Test/Runtime/ReadingResources.cs index afc34e8d1..7eb16da29 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Test/Runtime/ReadingResources.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Test/Runtime/ReadingResources.cs @@ -4,36 +4,33 @@ namespace Baked.Test.Runtime; public class ReadingResources : TestServiceNfr { - [TestCase("/Core/ApplicationPhysical.txt", "\"application physical\"")] - [TestCase("/Core/ApplicationEmbedded.txt", "\"application embedded\"")] - [TestCase("/Core/DomainEmbedded.txt", "\"domain embedded\"")] + [TestCase("/Core/ApplicationPhysical.txt", "application physical")] + [TestCase("/Core/ApplicationEmbedded.txt", "application embedded")] + [TestCase("/Core/DomainEmbedded.txt", "domain embedded")] public async Task Contents_of_a_resource_can_be_read(string subPath, string expected) { - Client.DefaultRequestHeaders.Authorization = GetFixedBearerToken("BaseClaims"); - var response = await Client.PostAsync("resource-samples/read", JsonContent.Create(new { subPath })); - var content = await response.Content.ReadAsStringAsync(); + var content = await response.Content.ReadFromJsonAsync(); content.ShouldBe(expected); response = await Client.PostAsync("resource-samples/read-async", JsonContent.Create(new { subPath })); - content = await response.Content.ReadAsStringAsync(); + content = await response.Content.ReadFromJsonAsync(); content.ShouldBe(expected); } - public async Task Returns_null_when_embedded_resource_does_not_exits() + [Test] + public async Task Returns_empty_string_when_embedded_resource_does_not_exits() { - Client.DefaultRequestHeaders.Authorization = GetFixedBearerToken("BaseClaims"); - var response = await Client.PostAsync("resource-samples/read", JsonContent.Create(new { subPath = "NotExistingResource.txt" })); - var content = await response.Content.ReadAsStringAsync(); + var content = await response.Content.ReadFromJsonAsync(); - content.ShouldBeNull(); + content.ShouldBeEmpty(); response = await Client.PostAsync("resource-samples/read-async", JsonContent.Create(new { subPath = "NotExistingResource.txt" })); - content = await response.Content.ReadAsStringAsync(); + content = await response.Content.ReadFromJsonAsync(); - content.ShouldBeNull(); + content.ShouldBeEmpty(); } } \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service.Test/TestServiceSpec.cs b/test/recipe/Baked.Test.Recipe.Service.Test/TestServiceSpec.cs index 4e793074a..ae9c0022d 100644 --- a/test/recipe/Baked.Test.Recipe.Service.Test/TestServiceSpec.cs +++ b/test/recipe/Baked.Test.Recipe.Service.Test/TestServiceSpec.cs @@ -7,7 +7,7 @@ public abstract class TestServiceSpec : ServiceSpec { static TestServiceSpec() => Init( - business: c => c.DomainAssemblies([typeof(Entity).Assembly]), + business: c => c.DomainAssemblies([typeof(Entity).Assembly], baseNamespace: "Baked.Test"), communication: c => c.Mock(defaultResponses: response => { response.ForClient(response: "test result"); @@ -16,6 +16,7 @@ static TestServiceSpec() => }), configure: app => { + app.Features.AddReporting(c => c.Mock()); app.Features.AddConfigurationOverrider(); } ); diff --git a/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj b/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj index 6ac1dd419..8d7cf9971 100644 --- a/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj +++ b/test/recipe/Baked.Test.Recipe.Service/Baked.Test.Recipe.Service.csproj @@ -6,15 +6,17 @@ + + - + - + diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs b/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs new file mode 100644 index 000000000..e76d083dc --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/EntityReportData.cs @@ -0,0 +1,3 @@ +namespace Baked.Test.Reporting; + +public record EntityReportData(int Count, string String); \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json b/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json new file mode 100644 index 000000000..114c93932 --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/Fake/entity.json @@ -0,0 +1,23 @@ +[ + { + "parameters": { "string": "test" }, + "result": [ + { "count" : 2, "string" : "test 1" }, + { "count" : 1, "string" : "test 2" } + ] + }, + { + "parameters": { "string": "filtered" }, + "result": [ + { "count" : 4, "string" : "filtered 1" }, + { "count" : 3, "string" : "filtered 2" } + ] + }, + { + "parameters": { "string": "reg.*" }, + "result": [ + { "count" : 6, "string" : "reg-x" }, + { "count" : 5, "string" : "reg-y" } + ] + } +] diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql new file mode 100644 index 000000000..b9cae181d --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/MySql/entity.sql @@ -0,0 +1,9 @@ +SELECT + count(e.Id), + e.String +FROM + Entity e +WHERE + e.String LIKE :string +GROUP BY + e.String diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql new file mode 100644 index 000000000..b9cae181d --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/PostgreSql/entity.sql @@ -0,0 +1,9 @@ +SELECT + count(e.Id), + e.String +FROM + Entity e +WHERE + e.String LIKE :string +GROUP BY + e.String diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs b/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs new file mode 100644 index 000000000..edbe859c6 --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/ReportSamples.cs @@ -0,0 +1,39 @@ +using Baked.Reporting; + +namespace Baked.Test.Reporting; + +public class ReportSamples(IReportContext _context) +{ + public async Task> GetEntity(string @string) => + (await _context.Execute("entity", + new() + { + { nameof(@string), $"{@string}%" } + } + )) + .Select(row => + new EntityReportData( + Convert.ToInt32(row[0]), + (string?)row[1] ?? string.Empty + ) + ) + .ToList(); + + public async Task GetNonExisting() + { + try + { + await _context.Execute("non-existing", []); + } + catch (QueryNotFoundException ex) + { + if (!ex.Message.Contains("non-existing")) { throw; } + + return; + } + catch + { + throw; + } + } +} \ No newline at end of file diff --git a/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql b/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql new file mode 100644 index 000000000..b9cae181d --- /dev/null +++ b/test/recipe/Baked.Test.Recipe.Service/Reporting/Sqlite/entity.sql @@ -0,0 +1,9 @@ +SELECT + count(e.Id), + e.String +FROM + Entity e +WHERE + e.String LIKE :string +GROUP BY + e.String diff --git a/unreleased.md b/unreleased.md index 6f5f86e95..68e877585 100644 --- a/unreleased.md +++ b/unreleased.md @@ -9,31 +9,34 @@ - `Monitoring` - `Oracle` implementation of `Database` feature is now added - `Cors` feature is now added with `AspNetCore` implementation + - `Reporting` feature is introduced with three implenmentations `NativeSql` + for production, `Mock` and `Fake` for development - `RichTransient` coding style feature is now added ## Improvements - `MockConfiguration` feature now clears `FakeSettings` list on teardown -- `MocMe.TheClient` helper now provides optional parameter to clear previous +- `MockMe.TheClient` helper now provides optional parameter to clear previous invocations -- `ConfigureAction` and `OverrideAction` helpers are now added to configure +- `ConfigureAction` and `OverrideAction` helpers are now added to configure `RestApi.ActionModel` before and after conventions - `Enum` helper class is added to use enum values within `ValueSource` attribute - `DataAccess` layer now exposes `FluentConfiguration` as configuration target -- `DataAccess` layer now introduces `IDatabaseInitializerCollection` +- `DataAccess` layer now introduces `IDatabaseInitializerCollection` configuration target for registering database initialization actions -- `IServiceProvider` now has `UseCurrentScope` extensions to resolve services - using the scope provided by `IServiceProviderAccessor` implementations -- `TestRun` now creates and disposes a scope for each test run to -- `Runtime` layer now provides `IFileProvider`component with +- `IServiceProvider` now has `UseCurrentScope` extensions to resolve services + using the scope provided by `IServiceProviderAccessor` implementations +- `TestRun` now creates and disposes a scope for each test run to +- `Runtime` layer now provides `IFileProvider`component with `CompositeFileProvider` implementation - `ReadAsString` and `ReadAsStringAsync` helper extensions are now added for - `IFileProvider` -- `DomainAssemblies` feature now have options to auto add embedded file - providers for give assemblies + `IFileProvider` +- `DomainAssemblies` feature now have options to auto add embedded file + providers for give assemblies - `Dotnet` feature now adds embedded and physical file providers for given - entry assembly + entry assembly +- Async overloads for `ShouldPass` and `ShouldFail` are now available ### Library Upgrades @@ -49,4 +52,4 @@ | NHibernate.Extensions.Sqlite | 8.0.13 | 8.0.14 | | Npgsql | 8.0.4 | 8.0.5 | | Swashbuckle.AspNetCore | 6.8.0 | 6.9.0 | -| Swashbuckle.AspNetCore.Annotations | 6.8.0 | 6.9.0 | \ No newline at end of file +| Swashbuckle.AspNetCore.Annotations | 6.8.0 | 6.9.0 |