diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 100fc1022f..88fe457aa9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,12 @@ jobs: with: username: postgres password: postgres + - name: Configure PostgreSQL + shell: bash + run: | + PGDATA="$RUNNER_TEMP/pgdata" + echo "max_connections = 500" >> "$PGDATA/postgresql.conf" + pg_ctl restart --pgdata="$PGDATA" --wait - name: Setup .NET uses: actions/setup-dotnet@v5 with: @@ -104,7 +110,22 @@ jobs: Logging__LogLevel__Microsoft.Extensions.Hosting.Internal.Host: 'None' Logging__LogLevel__Microsoft.EntityFrameworkCore.Database.Command: 'None' Logging__LogLevel__JsonApiDotNetCore: 'None' + JADNC_THROTTLE_DIAG: '1' run: dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;annotations-title=@test (@framework);annotations-message=@error\n@trace;summary-include-passed=false" + - name: Print throttle diagnostics + if: always() + shell: bash + run: | + shopt -s nullglob + files=("$RUNNER_TEMP"/jadnc-throttle-*.log) + if [[ ${#files[@]} -eq 0 ]]; then + echo "No throttle diagnostic files found." + else + for f in "${files[@]}"; do + echo "=== $f ===" + cat "$f" + done + fi - name: Upload coverage to codecov.io if: ${{ matrix.os == 'ubuntu-latest' }} env: diff --git a/src/Examples/DapperExample/appsettings.json b/src/Examples/DapperExample/appsettings.json index 7854646e7f..6856e6481d 100644 --- a/src/Examples/DapperExample/appsettings.json +++ b/src/Examples/DapperExample/appsettings.json @@ -3,7 +3,7 @@ "ConnectionStrings": { // docker run --rm --detach --name dapper-example-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest // docker run --rm --detach --name dapper-example-postgresql-management --link dapper-example-postgresql-db:db -e PGADMIN_DEFAULT_EMAIL=admin@admin.com -e PGADMIN_DEFAULT_PASSWORD=postgres -p 5050:80 dpage/pgadmin4:latest - "DapperExamplePostgreSql": "Host=localhost;Database=DapperExample;User ID=postgres;Password=postgres;Include Error Detail=true", + "DapperExamplePostgreSql": "Host=localhost;Database=DapperExample;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120", // docker run --rm --detach --name dapper-example-mysql-db -e MYSQL_ROOT_PASSWORD=mysql -e MYSQL_DATABASE=DapperExample -e MYSQL_USER=mysql -e MYSQL_PASSWORD=mysql -p 3306:3306 mysql:latest // docker run --rm --detach --name dapper-example-mysql-management --link dapper-example-mysql-db:db -p 8081:80 phpmyadmin/phpmyadmin "DapperExampleMySql": "Host=localhost;Database=DapperExample;User ID=mysql;Password=mysql;SSL Mode=None;AllowPublicKeyRetrieval=True", diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index 1b5a40da62..1389ed0e8d 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,8 +1,8 @@ { "ConnectionStrings": { - "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=postgres;Include Error Detail=true", - "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=postgres;Include Error Detail=true", - "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=postgres;Include Error Detail=true" + "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120", + "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120", + "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120" }, "Logging": { "LogLevel": { diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 418fcb7812..ed3d154dfc 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres;Include Error Detail=true" + "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120" }, "Logging": { "LogLevel": { diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 1fec12996f..54787e4b8f 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -128,7 +128,7 @@ private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType? resourc private string GetLinkForTopLevelSelf() { - // Note: in tests, this does not properly escape special characters due to WebApplicationFactory short-circuiting. + // Note: in tests, this does not properly escape special characters due to TestServer short-circuiting. return _options.UseRelativeLinks ? HttpContext.Request.GetEncodedPathAndQuery() : HttpContext.Request.GetEncodedUrl(); } diff --git a/test/DapperTests/IntegrationTests/DapperTestContext.cs b/test/DapperTests/IntegrationTests/DapperTestContext.cs index ea59dc895e..22c6ff7928 100644 --- a/test/DapperTests/IntegrationTests/DapperTestContext.cs +++ b/test/DapperTests/IntegrationTests/DapperTestContext.cs @@ -14,12 +14,13 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using TestBuildingBlocks; +using Xunit; using Xunit.Abstractions; namespace DapperTests.IntegrationTests; [PublicAPI] -public sealed class DapperTestContext : IntegrationTest +public sealed class DapperTestContext : IntegrationTest, IAsyncLifetime { private const string SqlServerClearAllTablesScript = """ EXEC sp_MSForEachTable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'; @@ -48,6 +49,11 @@ public DapperTestContext() _lazyFactory = new Lazy>(CreateFactory); } + public async Task InitializeAsync() + { + await AcquireDbThrottleAsync(); + } + private WebApplicationFactory CreateFactory() { #pragma warning disable CA2000 // Dispose objects before losing scope @@ -56,7 +62,7 @@ private WebApplicationFactory CreateFactory() #pragma warning restore CA2000 // Dispose objects before losing scope { builder.UseSetting("ConnectionStrings:DapperExamplePostgreSql", - $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"); + $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120"); builder.UseSetting("ConnectionStrings:DapperExampleMySql", $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=root;Password=mysql;SSL Mode=None;AllowPublicKeyRetrieval=True"); @@ -141,7 +147,7 @@ protected override HttpClient CreateClient() return Factory.CreateClient(); } - public override async Task DisposeAsync() + public async Task DisposeAsync() { try { @@ -159,7 +165,7 @@ public override async Task DisposeAsync() } finally { - await base.DisposeAsync(); + ReleaseDbThrottle(); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 14cfc466a0..f4b5478c32 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -28,7 +28,7 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext(); string dbConnectionString = - $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; + $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120"; services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 9c6b5c6a28..810438bf90 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -432,7 +432,7 @@ public async Task Denies_JsonApi_ContentType_header_with_CharSet() }; const string route = "/policies"; - string contentType = $"{JsonApiMediaType.Default}; charset=ISO-8859-4"; + string contentType = $"{JsonApiMediaType.Default}; charset=utf-8"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/SourcePointerInExceptionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/SourcePointerInExceptionTests.cs index 089a01d21e..42ac59c58d 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/SourcePointerInExceptionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/SourcePointerInExceptionTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Runtime.CompilerServices; using System.Text.Json; using FluentAssertions; using JetBrains.Annotations; @@ -91,6 +92,7 @@ private sealed class ThrowingResourceObjectConverter(IResourceGraph resourceGrap { private readonly string? _relativeSourcePointer = relativeSourcePointer; + [MethodImpl(MethodImplOptions.NoInlining)] private protected override void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, ResourceType resourceType, Utf8JsonReader reader) { diff --git a/test/OpenApiKiotaEndToEndTests/TestableHttpClientRequestAdapterFactory.cs b/test/OpenApiKiotaEndToEndTests/TestableHttpClientRequestAdapterFactory.cs index 40082ea7ee..9436f4ceb4 100644 --- a/test/OpenApiKiotaEndToEndTests/TestableHttpClientRequestAdapterFactory.cs +++ b/test/OpenApiKiotaEndToEndTests/TestableHttpClientRequestAdapterFactory.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.OpenApi.Client.Kiota; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Kiota.Abstractions.Authentication; using Microsoft.Kiota.Http.HttpClientLibrary; using Microsoft.Kiota.Http.HttpClientLibrary.Middleware; @@ -21,10 +20,9 @@ public TestableHttpClientRequestAdapterFactory(ITestOutputHelper testOutputHelpe _logHttpMessageHandler = new XUnitLogHttpMessageHandler(testOutputHelper); } - public HttpClientRequestAdapter CreateAdapter(WebApplicationFactory webApplicationFactory) - where TStartup : class + public HttpClientRequestAdapter CreateAdapter(FactoryBridge bridge) { - ArgumentNullException.ThrowIfNull(webApplicationFactory); + ArgumentNullException.ThrowIfNull(bridge); DelegatingHandler[] handlers = [ @@ -33,7 +31,7 @@ public HttpClientRequestAdapter CreateAdapter(WebApplicationFactory : IntegrationTestContext - where TStartup : class + where TStartup : IStartup, new() where TDbContext : TestableDbContext { private readonly Lazy> _lazyDocument; @@ -19,6 +19,7 @@ public class OpenApiTestContext : IntegrationTestContext>(CreateOpenApiDocumentAsync, LazyThreadSafetyMode.ExecutionAndPublication); } @@ -39,7 +40,7 @@ internal async Task CreateOpenApiDocumentAsync() return rootElement; } - internal void SetTestOutputHelper(ITestOutputHelper testOutputHelper) + internal void SetTestOutputHelper(ITestOutputHelper? testOutputHelper) { ArgumentNullException.ThrowIfNull(testOutputHelper); diff --git a/test/TestBuildingBlocks/FactoryBridge.cs b/test/TestBuildingBlocks/FactoryBridge.cs new file mode 100644 index 0000000000..6778e019d2 --- /dev/null +++ b/test/TestBuildingBlocks/FactoryBridge.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; + +namespace TestBuildingBlocks; + +/// +/// A temporary bridge to prevent adapting all existing tests. +/// +public sealed class FactoryBridge +{ + private readonly WebApplication _app; + private readonly bool _captureHttpTraffic; + private bool _hasStartedApp; + + public IServiceProvider Services => _app.Services; + + internal FactoryBridge(WebApplication app, bool captureHttpTraffic) + { + ArgumentNullException.ThrowIfNull(app); + + _app = app; + _captureHttpTraffic = captureHttpTraffic; + } + + public HttpClient CreateClient() + { + return GetTestClient(); + } + + public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers) + { + return GetTestClient(handlers); + } + + public HttpClient GetTestClient(params DelegatingHandler[] handlers) + { + if (!_hasStartedApp) + { + _hasStartedApp = true; + _app.Start(); + } + + _ = _captureHttpTraffic; + + if (handlers.Length == 0) + { + return _app.GetTestClient(); + } + + TestServer testServer = _app.GetTestServer(); + HttpMessageHandler serverHandler = testServer.CreateHandler(); + HttpClient httpClient = CreateHttpClient(serverHandler, handlers); + + httpClient.BaseAddress ??= new Uri("http://localhost"); + + return httpClient; + } + + private static HttpClient CreateHttpClient(HttpMessageHandler serverHandler, params DelegatingHandler[] handlers) + { + for (int i = handlers.Length - 1; i > 0; i--) + { + handlers[i - 1].InnerHandler = handlers[i]; + } + + handlers[^1].InnerHandler = serverHandler; + return new HttpClient(handlers[0]); + } +} diff --git a/test/TestBuildingBlocks/HttpClientWrapper.cs b/test/TestBuildingBlocks/HttpClientWrapper.cs new file mode 100644 index 0000000000..0fb8778f43 --- /dev/null +++ b/test/TestBuildingBlocks/HttpClientWrapper.cs @@ -0,0 +1,124 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using JsonApiDotNetCore.Middleware; + +namespace TestBuildingBlocks; + +/// +/// A wrapper for that conveniently enables executing HTTP requests against JSON:API endpoints. +/// +internal sealed class HttpClientWrapper +{ + private static readonly MediaTypeHeaderValue DefaultMediaType = MediaTypeHeaderValue.Parse(JsonApiMediaType.Default.ToString()); + + private static readonly MediaTypeWithQualityHeaderValue OperationsMediaType = + MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString()); + + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _serializerOptions; + + public HttpClientWrapper(HttpClient httpClient, JsonSerializerOptions serializerOptions) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(serializerOptions); + + _httpClient = httpClient; + _serializerOptions = serializerOptions; + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl, + Action? setRequestHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Head, requestUrl, null, null, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync(string requestUrl, + Action? setRequestHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, + object requestBody, string? contentType = null, Action? setRequestHeaders = null) + { + MediaTypeHeaderValue mediaType = contentType == null ? DefaultMediaType : MediaTypeHeaderValue.Parse(contentType); + + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, mediaType, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync(string requestUrl, + object requestBody) + { + Action setRequestHeaders = headers => headers.Accept.Add(OperationsMediaType); + + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, OperationsMediaType, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, + object requestBody, Action? setRequestHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl, + object? requestBody = null, Action? setRequestHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); + } + + private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteRequestAsync(HttpMethod method, + string requestUrl, object? requestBody, MediaTypeHeaderValue? contentType, Action? setRequestHeaders) + { + using var request = new HttpRequestMessage(method, requestUrl); + string? requestText = SerializeRequest(requestBody); + + if (!string.IsNullOrEmpty(requestText)) + { + requestText = requestText.Replace("atomic__", "atomic:"); + request.Content = new StringContent(requestText); + request.Content.Headers.ContentLength = Encoding.UTF8.GetByteCount(requestText); + + if (contentType != null) + { + request.Content.Headers.ContentType = contentType; + } + } + + setRequestHeaders?.Invoke(request.Headers); + + HttpResponseMessage responseMessage = await _httpClient.SendAsync(request); + + string responseText = await responseMessage.Content.ReadAsStringAsync(); + var responseDocument = DeserializeResponse(responseText); + + return (responseMessage, responseDocument!); + } + + private string? SerializeRequest(object? requestBody) + { + return requestBody == null ? null : requestBody as string ?? JsonSerializer.Serialize(requestBody, _serializerOptions); + } + + private TResponseDocument? DeserializeResponse(string responseText) + { + if (typeof(TResponseDocument) == typeof(string)) + { + return (TResponseDocument)(object)responseText; + } + + if (string.IsNullOrEmpty(responseText)) + { + return default; + } + + try + { + return JsonSerializer.Deserialize(responseText, _serializerOptions); + } + catch (JsonException exception) + { + throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); + } + } +} diff --git a/test/TestBuildingBlocks/IStartup.cs b/test/TestBuildingBlocks/IStartup.cs new file mode 100644 index 0000000000..bcadde4a16 --- /dev/null +++ b/test/TestBuildingBlocks/IStartup.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace TestBuildingBlocks; + +public interface IStartup +{ + void ConfigureServices(IServiceCollection services); + + void Configure(IApplicationBuilder app); +} diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index b67b1e1e29..968e367571 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -1,141 +1,247 @@ +using System.Diagnostics; using System.Net.Http.Headers; -using System.Text; using System.Text.Json; using FluentAssertions.Extensions; -using JsonApiDotNetCore.Middleware; -using Xunit; +using Microsoft.Extensions.DependencyInjection; namespace TestBuildingBlocks; /// -/// A base class for tests that conveniently enables executing HTTP requests against JSON:API endpoints. It throttles tests that are running in parallel -/// to avoid exceeding the maximum active database connections. +/// A base class for tests that conveniently enables executing HTTP requests against JSON:API endpoints. Tests that use a database should call +/// and to avoid exceeding the maximum active database connections. /// -public abstract class IntegrationTest : IAsyncLifetime +public abstract class IntegrationTest { - private static readonly MediaTypeHeaderValue DefaultMediaType = MediaTypeHeaderValue.Parse(JsonApiMediaType.Default.ToString()); + private const string ThrottleEnvironmentVariableName = "JADNC_DB_THROTTLE"; + private const string DiagnosticsEnvironmentVariableName = "JADNC_THROTTLE_DIAG"; - private static readonly MediaTypeWithQualityHeaderValue OperationsMediaType = - MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString()); + // Process-local semaphore that limits the number of concurrently live test databases to avoid exhausting the PostgreSQL connection pool. + private static readonly SemaphoreSlim DatabaseThrottleSemaphore = CreateThrottleSemaphore(); + protected static readonly Action ConfigureServiceProvider = static options => options.ValidateScopes = true; + private static int _acquiredSlotCount; + private static readonly bool DiagnosticsEnabled = Environment.GetEnvironmentVariable(DiagnosticsEnvironmentVariableName) == "1"; - private static readonly SemaphoreSlim ThrottleSemaphore = GetDefaultThrottleSemaphore(); + private static readonly Stopwatch DiagStopwatch = Stopwatch.StartNew(); + + // When running in GitHub Actions, RUNNER_TEMP is a well-known writable directory that the workflow step can read back. + // Locally, fall back to the system temp directory. + private static readonly string DiagFilePath = Path.Combine(Environment.GetEnvironmentVariable("RUNNER_TEMP") ?? Path.GetTempPath(), + $"jadnc-throttle-{Environment.ProcessId}.log"); +#pragma warning disable IDE0330 // Use 'System.Threading.Lock' — not available on net8.0 + private static readonly object DiagFileLock = new(); +#pragma warning restore IDE0330 + + // Diagnostics counters (only updated when diagnostics are enabled). + + // Tracks how many slots this process has acquired, so they can be released on abnormal exit. + + private static int _diagCurrentActive; + private static int _diagPeakActive; + private static int _diagWaitedCount; + private static int _diagAcquiredTotal; + + // Tracks whether this instance has acquired a slot, so DisposeAsync cannot double-release on error paths. + private bool _throttleAcquired; + private long _slotAcquiredTimestamp; public static DateTimeOffset DefaultDateTimeUtc { get; } = 1.January(2020).At(1, 2, 3).AsUtc(); protected abstract JsonSerializerOptions SerializerOptions { get; } - private static SemaphoreSlim GetDefaultThrottleSemaphore() + static IntegrationTest() { - int maxConcurrentTestRuns = OperatingSystem.IsWindows() && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSAPPIDDIR")) ? 32 : 64; - return new SemaphoreSlim(maxConcurrentTestRuns); - } + // Fires on normal exit, Environment.Exit(), VS Stop (graceful), and CLI Ctrl+C. + // Releases all slots this process holds so the next test run is not blocked. + // On Windows, forceful process kills cannot be caught here; the OS closes the handle but does not adjust the named + // semaphore count. In that scenario the 5-minute timeout in AcquireDbThrottle() produces a clear error message. + AppDomain.CurrentDomain.ProcessExit += static (_, _) => + { + ReleaseAllAcquiredSlots(); - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl, - Action? setRequestHeaders = null) - { - return await ExecuteRequestAsync(HttpMethod.Head, requestUrl, null, null, setRequestHeaders); - } + if (DiagnosticsEnabled) + { + WriteDiagLine( + $"[THROTTLE] EXIT summary: peak={_diagPeakActive}, waited={_diagWaitedCount}/{_diagAcquiredTotal}, elapsed={DiagStopwatch.Elapsed:mm\\:ss\\.f}"); + } + }; - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync(string requestUrl, - Action? setRequestHeaders = null) - { - return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, setRequestHeaders); + if (DiagnosticsEnabled) + { + WriteDiagLine( + $"[THROTTLE] INIT pid={Environment.ProcessId}, os={Environment.OSVersion.Platform}, cpus={Environment.ProcessorCount}, limit={GetThrottleLimit()}, file={DiagFilePath}"); + } } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, - object requestBody, string? contentType = null, Action? setRequestHeaders = null) + private static void WriteDiagLine(string message) { - MediaTypeHeaderValue mediaType = contentType == null ? DefaultMediaType : MediaTypeHeaderValue.Parse(contentType); - - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, mediaType, setRequestHeaders); + lock (DiagFileLock) + { + File.AppendAllText(DiagFilePath, message + Environment.NewLine); + } } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync(string requestUrl, - object requestBody) + private static int GetThrottleLimit() { - Action setRequestHeaders = headers => headers.Accept.Add(OperationsMediaType); + string? overrideValue = Environment.GetEnvironmentVariable(ThrottleEnvironmentVariableName); - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, OperationsMediaType, setRequestHeaders); + if (int.TryParse(overrideValue, out int parsed) && parsed > 0) + { + return parsed; + } + + // On Windows outside Visual Studio, use a lower limit to avoid overwhelming PostgreSQL with too many simultaneous connections. + return OperatingSystem.IsWindows() && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VSAPPIDDIR")) ? 32 : 64; } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, - object requestBody, Action? setRequestHeaders = null) + private static SemaphoreSlim CreateThrottleSemaphore() { - return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); + return new SemaphoreSlim(GetThrottleLimit()); } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl, - object? requestBody = null, Action? setRequestHeaders = null) + private static void ReleaseAllAcquiredSlots() { - return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); + int slots = Interlocked.Exchange(ref _acquiredSlotCount, 0); + + if (slots > 0) + { + DatabaseThrottleSemaphore.Release(slots); + } } - private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteRequestAsync(HttpMethod method, - string requestUrl, object? requestBody, MediaTypeHeaderValue? contentType, Action? setRequestHeaders) + protected async Task AcquireDbThrottleAsync() { - using var request = new HttpRequestMessage(method, requestUrl); - string? requestText = SerializeRequest(requestBody); + bool hadToWait = false; + bool acquired; - if (!string.IsNullOrEmpty(requestText)) + if (DiagnosticsEnabled) { - requestText = requestText.Replace("atomic__", "atomic:"); - request.Content = new StringContent(requestText); - request.Content.Headers.ContentLength = Encoding.UTF8.GetByteCount(requestText); + // Try a non-blocking acquire first to detect whether the semaphore is actually saturated. + acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.Zero); - if (contentType != null) + if (!acquired) { - request.Content.Headers.ContentType = contentType; + hadToWait = true; + Interlocked.Increment(ref _diagWaitedCount); + + WriteDiagLine($"[THROTTLE] WAITING at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={_diagCurrentActive}, peak={_diagPeakActive}"); + + // The 10-minute timeout is a safety net against indefinite hangs caused by leaked slots (e.g. after a + // forcibly killed run on Windows). Under normal conditions fixtures queue up and are served as earlier + // ones complete, which happens well within this window. + acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(10)); } } + else + { + // The 10-minute timeout is a safety net against indefinite hangs caused by leaked slots (e.g. after a + // forcibly killed run on Windows). Under normal conditions fixtures queue up and are served as earlier + // ones complete, which happens well within this window. + acquired = await DatabaseThrottleSemaphore.WaitAsync(TimeSpan.FromMinutes(10)); + } + + if (!acquired) + { + throw new TimeoutException($"Timed out waiting for a database test slot after 10 minutes " + + $"[diag: active={_diagCurrentActive}, peak={_diagPeakActive}, waited={_diagWaitedCount}]. " + + $"Set the '{ThrottleEnvironmentVariableName}' environment variable to override the slot count. " + + "On Windows, restarting all running test processes clears leaked slots from a previously killed run."); + } - setRequestHeaders?.Invoke(request.Headers); + Interlocked.Increment(ref _acquiredSlotCount); + _throttleAcquired = true; - using HttpClient client = CreateClient(); - HttpResponseMessage responseMessage = await client.SendAsync(request); + if (DiagnosticsEnabled) + { + int active = Interlocked.Increment(ref _diagCurrentActive); + Interlocked.Increment(ref _diagAcquiredTotal); + + int peak; + + do + { + peak = _diagPeakActive; + + if (active <= peak) + { + break; + } + } + while (Interlocked.CompareExchange(ref _diagPeakActive, active, peak) != peak); - string responseText = await responseMessage.Content.ReadAsStringAsync(); - var responseDocument = DeserializeResponse(responseText); + _slotAcquiredTimestamp = Stopwatch.GetTimestamp(); - return (responseMessage, responseDocument!); + WriteDiagLine($"[THROTTLE] ACQUIRED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, peak={Math.Max(active, peak)}, waited={hadToWait}"); + } } - private string? SerializeRequest(object? requestBody) + /// + /// Releases the database slot acquired by . + /// + protected void ReleaseDbThrottle() { - return requestBody == null ? null : requestBody as string ?? JsonSerializer.Serialize(requestBody, SerializerOptions); + if (_throttleAcquired) + { + _throttleAcquired = false; + Interlocked.Decrement(ref _acquiredSlotCount); + DatabaseThrottleSemaphore.Release(); + + if (DiagnosticsEnabled) + { + int active = Interlocked.Decrement(ref _diagCurrentActive); + double heldMs = Stopwatch.GetElapsedTime(_slotAcquiredTimestamp).TotalMilliseconds; + + WriteDiagLine($"[THROTTLE] RELEASED at {DiagStopwatch.Elapsed:mm\\:ss\\.f}, active={active}, held={heldMs:F0}ms"); + } + } } - protected abstract HttpClient CreateClient(); + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteHeadAsync(string requestUrl, + Action? setRequestHeaders = null) + { + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecuteHeadAsync(requestUrl, setRequestHeaders); + } - private TResponseDocument? DeserializeResponse(string responseText) + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteGetAsync(string requestUrl, + Action? setRequestHeaders = null) { - if (typeof(TResponseDocument) == typeof(string)) - { - return (TResponseDocument)(object)responseText; - } + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecuteGetAsync(requestUrl, setRequestHeaders); + } - if (string.IsNullOrEmpty(responseText)) - { - return default; - } + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, + object requestBody, string? contentType = null, Action? setRequestHeaders = null) + { + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecutePostAsync(requestUrl, requestBody, contentType, setRequestHeaders); + } - try - { - return JsonSerializer.Deserialize(responseText, SerializerOptions); - } - catch (JsonException exception) - { - throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); - } + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync(string requestUrl, + object requestBody) + { + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecutePostAtomicAsync(requestUrl, requestBody); } - public async Task InitializeAsync() + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, + object requestBody, Action? setRequestHeaders = null) { - await ThrottleSemaphore.WaitAsync(); + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecutePatchAsync(requestUrl, requestBody, setRequestHeaders); } - public virtual Task DisposeAsync() + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl, + object? requestBody = null, Action? setRequestHeaders = null) { - _ = ThrottleSemaphore.Release(); - return Task.CompletedTask; + using HttpClient httpClient = CreateClient(); + var wrapper = new HttpClientWrapper(httpClient, SerializerOptions); + return await wrapper.ExecuteDeleteAsync(requestUrl, requestBody, setRequestHeaders); } + + protected abstract HttpClient CreateClient(); } diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 36672cb3b3..7460ea05ad 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -2,9 +2,8 @@ using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -12,11 +11,12 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Xunit; namespace TestBuildingBlocks; /// -/// Base class for a test context that creates a new database and server instance before running tests and cleans up afterwards. You can either use this +/// Base class for a test context that creates a new database and server instance before running tests and cleans up afterward. You can either use this /// as a fixture on your tests class (init/cleanup runs once before/after all tests) or have your tests class inherit from it (init/cleanup runs once /// before/after each test). See for details on shared context usage. /// @@ -27,30 +27,72 @@ namespace TestBuildingBlocks; /// The Entity Framework Core database context, which can be defined in the test project or API project. /// [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public class IntegrationTestContext : IntegrationTest - where TStartup : class +public class IntegrationTestContext : IntegrationTest, IAsyncLifetime + where TStartup : IStartup, new() where TDbContext : TestableDbContext { - private readonly Lazy> _lazyFactory; private readonly TestControllerProvider _testControllerProvider = new(); - private Action? _loggingConfiguration; + private readonly Lazy _lazyApp; private Action? _configureServices; private Action? _postConfigureServices; + private Action? _configureLogging; + private Task? _dbReadyTask; + + private WebApplication App => _lazyApp.Value; + + protected bool CaptureHttpTraffic { get; init; } = true; protected override JsonSerializerOptions SerializerOptions { get { - var options = Factory.Services.GetRequiredService(); + var options = App.Services.GetRequiredService(); return options.SerializerOptions; } } - public WebApplicationFactory Factory => _lazyFactory.Value; + public FactoryBridge Factory + { + get + { + field ??= new FactoryBridge(App, CaptureHttpTraffic); + return field; + } + } public IntegrationTestContext() { - _lazyFactory = new Lazy>(CreateFactory); + _lazyApp = new Lazy(BuildApp, LazyThreadSafetyMode.ExecutionAndPublication); + } + + public void ConfigureServices(Action configureServices) + { + if (_configureServices != null && _configureServices != configureServices) + { + throw new InvalidOperationException($"Do not call {nameof(ConfigureServices)} multiple times."); + } + + _configureServices = configureServices; + } + + public void PostConfigureServices(Action postConfigureServices) + { + if (_postConfigureServices != null && _postConfigureServices != postConfigureServices) + { + throw new InvalidOperationException($"Do not call {nameof(PostConfigureServices)} multiple times."); + } + + _postConfigureServices = postConfigureServices; + } + + public void ConfigureLogging(Action configureLogging) + { + if (_configureLogging != null && _configureLogging != configureLogging) + { + throw new InvalidOperationException($"Do not call {nameof(ConfigureLogging)} multiple times."); + } + + _configureLogging = configureLogging; } public void UseController() @@ -59,41 +101,86 @@ public void UseController() _testControllerProvider.AddController(typeof(TController)); } - protected override HttpClient CreateClient() + private WebApplication BuildApp() { - return Factory.CreateClient(); - } + var startup = new TStartup(); - private WebApplicationFactory CreateFactory() - { - string dbConnectionString = $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; + WebApplicationBuilder builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions + { + ApplicationName = startup.GetType().Assembly.GetName().Name + }); - var factory = new IntegrationTestWebApplicationFactory(); + _configureServices?.Invoke(builder.Services); + startup.ConfigureServices(builder.Services); + _postConfigureServices?.Invoke(builder.Services); - factory.ConfigureLogging(_loggingConfiguration); + builder.Services.TryAddSingleton(new FrozenTimeProvider(DefaultDateTimeUtc)); + builder.Services.ReplaceControllers(_testControllerProvider); - factory.ConfigureServices(services => + string dbConnectionString = + $"Host=localhost;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true;Command Timeout=120"; + + builder.Services.AddDbContext(options => { - _configureServices?.Invoke(services); + options.UseNpgsql(dbConnectionString, static optionsBuilder => optionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); + SetDbContextDebugOptions(options); + }); - services.TryAddSingleton(new FrozenTimeProvider(DefaultDateTimeUtc)); + if (_configureLogging == null) + { + ConfigureMinLevels(builder); + } + else + { + _configureLogging.Invoke(builder.Logging); + } - services.ReplaceControllers(_testControllerProvider); + builder.Host.UseDefaultServiceProvider(ConfigureServiceProvider); + builder.WebHost.UseTestServer(); - services.AddDbContext(options => - { - options.UseNpgsql(dbConnectionString, builder => builder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); - SetDbContextDebugOptions(options); - }); + WebApplication app = builder.Build(); + + // Start DB schema creation asynchronously. With many fixtures starting concurrently, + // a synchronous EnsureCreated() here would block thread-pool threads and starve the + // SemaphoreSlim used by the DB throttle, causing timeouts. + Task dbReadyTask = _dbReadyTask = EnsureDbCreatedAsync(app); + + // Runs before startup middleware, ensuring the schema exists when the first request arrives. + app.Use(async (context, next) => + { + await dbReadyTask; + await next(context); }); - factory.PostConfigureServices(_postConfigureServices); + startup.Configure(app); - using IServiceScope scope = factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Database.EnsureCreated(); + return app; + } - return factory; + [Conditional("RELEASE")] + private static void ConfigureMinLevels(WebApplicationBuilder builder) + { + /* + var appSettings = new Dictionary + { + ["Logging:LogLevel:Default"] = "Warning", + ["Logging:LogLevel:Microsoft.AspNetCore.Hosting.Diagnostics"] = "None", + ["Logging:LogLevel:Microsoft.Hosting.Lifetime"] = "Warning", + ["Logging:LogLevel:Microsoft.EntityFrameworkCore"] = "Warning", + ["Logging:LogLevel:Microsoft.EntityFrameworkCore.Model.Validation"] = "Critical", + ["Logging:LogLevel:Microsoft.EntityFrameworkCore.Update"] = "Critical", + ["Logging:LogLevel:Microsoft.EntityFrameworkCore.Database.Command"] = "Critical", + ["Logging:LogLevel:JsonApiDotNetCore"] = "Critical" + }; + + var configurationBuilder = new ConfigurationBuilder(); + configurationBuilder.AddInMemoryCollection(appSettings); + IConfigurationRoot configuration = configurationBuilder.Build(); + + builder.Logging.AddConfiguration(configuration); + */ + + builder.Logging.ClearProviders(); } [Conditional("DEBUG")] @@ -101,117 +188,57 @@ private static void SetDbContextDebugOptions(DbContextOptionsBuilder options) { options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(); - options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); + options.ConfigureWarnings(static builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); } - public void ConfigureLogging(Action loggingConfiguration) + private static async Task EnsureDbCreatedAsync(WebApplication app) { - if (_loggingConfiguration != null && _loggingConfiguration != loggingConfiguration) - { - throw new InvalidOperationException($"Do not call {nameof(ConfigureLogging)} multiple times."); - } + await using AsyncServiceScope scope = app.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); - _loggingConfiguration = loggingConfiguration; + await dbContext.Database.EnsureCreatedAsync(); } - public void ConfigureServices(Action configureServices) + public async Task RunOnDatabaseAsync(Func asyncAction) { - if (_configureServices != null && _configureServices != configureServices) - { - throw new InvalidOperationException($"Do not call {nameof(ConfigureServices)} multiple times."); - } + await using AsyncServiceScope scope = App.Services.CreateAsyncScope(); - _configureServices = configureServices; - } + // App access above triggers BuildApp() if needed, which sets _dbReadyTask. + // Wait for DB schema creation before accessing the database. + await _dbReadyTask!; - public void PostConfigureServices(Action postConfigureServices) - { - if (_postConfigureServices != null && _postConfigureServices != postConfigureServices) - { - throw new InvalidOperationException($"Do not call {nameof(PostConfigureServices)} multiple times."); - } + var dbContext = scope.ServiceProvider.GetRequiredService(); - _postConfigureServices = postConfigureServices; + await asyncAction(dbContext); } - public async Task RunOnDatabaseAsync(Func asyncAction) + protected override HttpClient CreateClient() { - await using AsyncServiceScope scope = Factory.Services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); + return Factory.CreateClient(); + } - await asyncAction(dbContext); + public async Task InitializeAsync() + { + await AcquireDbThrottleAsync(); } - public override async Task DisposeAsync() + public virtual async Task DisposeAsync() { try { - if (_lazyFactory.IsValueCreated) + if (_lazyApp.IsValueCreated) { - await RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync()); - await _lazyFactory.Value.DisposeAsync(); + if (_dbReadyTask?.IsCompletedSuccessfully == true) + { + await RunOnDatabaseAsync(static async dbContext => await dbContext.Database.EnsureDeletedAsync()); + } + + await App.DisposeAsync(); } } finally { - await base.DisposeAsync(); - } - } - - private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory - { - private Action? _loggingConfiguration; - private Action? _configureServices; - private Action? _postConfigureServices; - - public void ConfigureLogging(Action? loggingConfiguration) - { - _loggingConfiguration = loggingConfiguration; - } - - public void ConfigureServices(Action? configureServices) - { - _configureServices = configureServices; - } - - public void PostConfigureServices(Action? configureServices) - { - _postConfigureServices = configureServices; - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - // We have placed an appsettings.json in the TestBuildingBlocks project directory and set the content root to there. Note that - // controllers are not discovered in the content root, but are registered manually using IntegrationTestContext.UseController. - builder.UseSolutionRelativeContentRoot($"test/{nameof(TestBuildingBlocks)}"); - } - - protected override IHostBuilder CreateHostBuilder() - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_before_first_method_call true - - return Host - .CreateDefaultBuilder(null) - .ConfigureAppConfiguration(builder => - { - // For tests asserting on log output, we discard the log levels from appsettings.json and environment variables. - // But using appsettings.json for all other tests makes it easy to quickly toggle when debugging tests. - if (_loggingConfiguration != null) - { - builder.Sources.Clear(); - } - }) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.ConfigureServices(services => _configureServices?.Invoke(services)); - webBuilder.UseStartup(); - webBuilder.ConfigureServices(services => _postConfigureServices?.Invoke(services)); - }) - .ConfigureLogging(options => _loggingConfiguration?.Invoke(options)); - - // @formatter:wrap_before_first_method_call restore - // @formatter:wrap_chained_method_calls restore + ReleaseDbThrottle(); } } } diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 3174e64374..ec28b715d3 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -1,4 +1,4 @@ - + net10.0;net9.0;net8.0 diff --git a/test/TestBuildingBlocks/TestableStartup.cs b/test/TestBuildingBlocks/TestableStartup.cs index 7a4f750477..34d7420a92 100644 --- a/test/TestBuildingBlocks/TestableStartup.cs +++ b/test/TestBuildingBlocks/TestableStartup.cs @@ -4,7 +4,7 @@ namespace TestBuildingBlocks; -public class TestableStartup +public class TestableStartup : IStartup where TDbContext : TestableDbContext { public virtual void ConfigureServices(IServiceCollection services) diff --git a/test/TestBuildingBlocks/appsettings.json b/test/TestBuildingBlocks/appsettings.json deleted file mode 100644 index c60110712a..0000000000 --- a/test/TestBuildingBlocks/appsettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Warning", - // Disable logging to keep the output from C/I build clean. Errors are expected to occur while testing failure handling. - "Microsoft.AspNetCore.Hosting.Diagnostics": "None", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore": "Warning", - "Microsoft.EntityFrameworkCore.Model.Validation": "Critical", - "Microsoft.EntityFrameworkCore.Update": "Critical", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore": "Critical" - } - } -}