diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx
index 5f4adbd622..6b9ad8af61 100644
--- a/framework/Volo.Abp.slnx
+++ b/framework/Volo.Abp.slnx
@@ -194,6 +194,7 @@
+
diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUnitOfWorkOptions.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUnitOfWorkOptions.cs
index 3949bb6639..a38d09f1fb 100644
--- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUnitOfWorkOptions.cs
+++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUnitOfWorkOptions.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
namespace Volo.Abp.AspNetCore.Uow;
@@ -11,4 +11,29 @@ public class AbpAspNetCoreUnitOfWorkOptions
/// starting with an ignored URL.
///
public List IgnoredUrls { get; } = new List();
+
+ ///
+ /// Completes the request unit of work just before the response starts (on
+ /// HttpResponse.OnStarting) instead of at the end of the pipeline, so data written during
+ /// the request is committed before the response is flushed. Disabled by default; enable it here
+ /// globally or opt-in per endpoint via .
+ ///
+ /// Trade-offs when it applies: an exception after the response starts can no longer roll back the
+ /// committed data (commit and network response are not atomic); database access after the response
+ /// starts is outside the request unit of work (unsuitable for streaming responses); unit of work
+ /// events and completed handlers run before the first response byte (adding to its latency); a
+ /// nested (requiresNew) unit of work that is current when the response starts, and an active child
+ /// unit of work scope (begun without requiresNew), are left to their owners and the request unit of
+ /// work then completes at the end of the pipeline as usual.
+ ///
+ ///
+ public bool CompleteUnitOfWorkOnResponseStarting { get; set; } = false;
+
+ ///
+ /// Request path prefixes that opt-in to even when
+ /// it is globally disabled. A request whose path starts with one of these values (for example
+ /// "/connect") is included, matched like . Use
+ /// to enable it for every request handled by the middleware.
+ ///
+ public List CompleteUnitOfWorkOnResponseStartingUrls { get; } = new List();
}
diff --git a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs
index 74833bf748..64c507d260 100644
--- a/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs
+++ b/framework/src/Volo.Abp.AspNetCore/Volo/Abp/AspNetCore/Uow/AbpUnitOfWorkMiddleware.cs
@@ -37,8 +37,36 @@ public class AbpUnitOfWorkMiddleware : AbpMiddlewareBase, ITransientDependency
using (var uow = _unitOfWorkManager.Reserve(UnitOfWork.UnitOfWorkReservationName))
{
+ var completionStarted = false;
+
+ if (!context.Response.HasStarted && ShouldCompleteOnResponseStarting(context))
+ {
+ context.Response.OnStarting(async () =>
+ {
+ // Skip if the completion has already been started at the end of the pipeline;
+ // the response is then being started from inside that completion (e.g. by an
+ // event handler writing to the response), so completing again would fail.
+ // A nested (requiresNew) unit of work that is current and an active child
+ // unit of work scope are left to their owners; the request unit of work then
+ // completes at the end of the pipeline as usual.
+ if (!completionStarted &&
+ _unitOfWorkManager.Current == uow &&
+ !uow.HasActiveChildUnitOfWorks())
+ {
+ // Set before completing so a post-commit failure isn't masked by the completion below.
+ completionStarted = true;
+ await uow.CompleteAsync(_cancellationTokenProvider.Token);
+ }
+ });
+ }
+
await next(context);
- await uow.CompleteAsync(_cancellationTokenProvider.Token);
+
+ if (!completionStarted)
+ {
+ completionStarted = true;
+ await uow.CompleteAsync(_cancellationTokenProvider.Token);
+ }
}
}
@@ -48,6 +76,13 @@ public class AbpUnitOfWorkMiddleware : AbpMiddlewareBase, ITransientDependency
_options.IgnoredUrls.Any(x => context.Request.Path.Value.StartsWith(x, StringComparison.OrdinalIgnoreCase));
}
+ private bool ShouldCompleteOnResponseStarting(HttpContext context)
+ {
+ return _options.CompleteUnitOfWorkOnResponseStarting ||
+ (context.Request.Path.Value != null &&
+ _options.CompleteUnitOfWorkOnResponseStartingUrls.Any(x => context.Request.Path.Value.StartsWith(x, StringComparison.OrdinalIgnoreCase)));
+ }
+
protected async override Task ShouldSkipAsync(HttpContext context, RequestDelegate next)
{
// Blazor components will render concurrently, so we need to skip the middleware for them.
diff --git a/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/ChildUnitOfWork.cs b/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/ChildUnitOfWork.cs
index 5f400abcfd..14625aaf4a 100644
--- a/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/ChildUnitOfWork.cs
+++ b/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/ChildUnitOfWork.cs
@@ -30,12 +30,14 @@ internal class ChildUnitOfWork : IUnitOfWork
public Dictionary Items => _parent.Items;
private readonly IUnitOfWork _parent;
+ private bool _isDisposed;
public ChildUnitOfWork([NotNull] IUnitOfWork parent)
{
Check.NotNull(parent, nameof(parent));
_parent = parent;
+ _parent.IncrementActiveChildUnitOfWorkCount();
_parent.Failed += (sender, args) => { Failed.InvokeSafely(sender!, args); };
_parent.Disposed += (sender, args) => { Disposed.InvokeSafely(sender!, args); };
@@ -122,7 +124,13 @@ internal class ChildUnitOfWork : IUnitOfWork
public void Dispose()
{
+ if (_isDisposed)
+ {
+ return;
+ }
+ _isDisposed = true;
+ _parent.DecrementActiveChildUnitOfWorkCount();
}
public override string ToString()
diff --git a/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/UnitOfWorkExtensions.cs b/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/UnitOfWorkExtensions.cs
index a732e5ad21..a037023472 100644
--- a/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/UnitOfWorkExtensions.cs
+++ b/framework/src/Volo.Abp.Uow/Volo/Abp/Uow/UnitOfWorkExtensions.cs
@@ -7,6 +7,8 @@ namespace Volo.Abp.Uow;
public static class UnitOfWorkExtensions
{
+ private const string ActiveChildUnitOfWorkCountItemKey = "_AbpActiveChildUnitOfWorkCount";
+
public static bool IsReservedFor([NotNull] this IUnitOfWork unitOfWork, string reservationName)
{
Check.NotNull(unitOfWork, nameof(unitOfWork));
@@ -14,6 +16,31 @@ public static class UnitOfWorkExtensions
return unitOfWork.IsReserved && unitOfWork.ReservationName == reservationName;
}
+ ///
+ /// Checks if there is an active (not yet disposed) child unit of work scope over the given
+ /// unit of work, i.e. a scope created by without
+ /// requiresNew while this unit of work was current. Such a scope shares this unit of work,
+ /// so it should not be completed while the scope is still active.
+ ///
+ public static bool HasActiveChildUnitOfWorks([NotNull] this IUnitOfWork unitOfWork)
+ {
+ Check.NotNull(unitOfWork, nameof(unitOfWork));
+
+ return unitOfWork.Items.GetOrDefault(ActiveChildUnitOfWorkCountItemKey) is int count && count > 0;
+ }
+
+ internal static void IncrementActiveChildUnitOfWorkCount(this IUnitOfWork unitOfWork)
+ {
+ var count = unitOfWork.Items.GetOrDefault(ActiveChildUnitOfWorkCountItemKey) as int? ?? 0;
+ unitOfWork.Items[ActiveChildUnitOfWorkCountItemKey] = count + 1;
+ }
+
+ internal static void DecrementActiveChildUnitOfWorkCount(this IUnitOfWork unitOfWork)
+ {
+ var count = unitOfWork.Items.GetOrDefault(ActiveChildUnitOfWorkCountItemKey) as int? ?? 0;
+ unitOfWork.Items[ActiveChildUnitOfWorkCountItemKey] = Math.Max(0, count - 1);
+ }
+
public static void AddItem([NotNull] this IUnitOfWork unitOfWork, string key, TValue value)
where TValue : class
{
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/ResponseWritingTestEvent.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/ResponseWritingTestEvent.cs
new file mode 100644
index 0000000000..989ec5b294
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/ResponseWritingTestEvent.cs
@@ -0,0 +1,34 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.EventBus;
+
+namespace Volo.Abp.AspNetCore.Mvc.Uow;
+
+public class ResponseWritingTestEvent
+{
+}
+
+///
+/// Writes to the HTTP response from a local event handler. When the event is published inside the
+/// request unit of work, this runs during the unit of work completion at the end of the pipeline
+/// and starts the response from inside that completion.
+///
+public class ResponseWritingTestEventHandler : ILocalEventHandler, ITransientDependency
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+
+ public ResponseWritingTestEventHandler(IHttpContextAccessor httpContextAccessor)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ }
+
+ public async Task HandleEventAsync(ResponseWritingTestEvent eventData)
+ {
+ var response = _httpContextAccessor.HttpContext?.Response;
+ if (response != null)
+ {
+ await response.WriteAsync("event-written");
+ }
+ }
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/TestUnitOfWorkConfig.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/TestUnitOfWorkConfig.cs
index 8f40f4b8e0..9dd87b56b1 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/TestUnitOfWorkConfig.cs
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/TestUnitOfWorkConfig.cs
@@ -7,4 +7,6 @@ public class TestUnitOfWorkConfig : ISingletonDependency
public const string ExceptionOnCompleteMessage = "TestUnitOfWork configured for exception";
public bool ThrowExceptionOnComplete { get; set; }
+
+ public bool? UowCompletedAfterResponseFlush { get; set; }
}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs
index 05e5d7b524..50d058903e 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkMiddleware_Tests.cs
@@ -1,12 +1,19 @@
-using System.Net.Http;
+using System.Net;
+using System.Net.Http;
using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using Shouldly;
+using Volo.Abp.AspNetCore.Uow;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Uow;
public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase
{
+ private AbpAspNetCoreUnitOfWorkOptions Options =>
+ ServiceProvider.GetRequiredService>().Value;
+
[Fact]
public async Task Get_Actions_Should_Not_Be_Transactional()
{
@@ -27,4 +34,114 @@ public class UnitOfWorkMiddleware_Tests : AspNetCoreMvcTestBase
var result = await Client.SendAsync(requestMessage);
result.IsSuccessStatusCode.ShouldBeTrue();
}
+
+ [Fact]
+ public async Task Ambient_Uow_Should_Be_Completed_Before_Response_Is_Flushed_When_Enabled()
+ {
+ Options.CompleteUnitOfWorkOnResponseStarting = true;
+
+ var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
+ result.ShouldBe("first:completed");
+ }
+
+ [Fact]
+ public async Task Ambient_Uow_Is_Not_Completed_On_Response_Start_By_Default()
+ {
+ var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
+ result.ShouldBe("first:not-completed");
+ }
+
+ [Fact]
+ public async Task Ambient_Uow_Is_Already_Completed_When_An_Exception_Is_Raised_After_The_Response_Started()
+ {
+ Options.CompleteUnitOfWorkOnResponseStarting = true;
+
+ // Once the response has started, an exception can't turn it into an error response (the
+ // connection is reset). Database-level rollback/commit is covered by the relational tests.
+ await Should.ThrowAsync(async () =>
+ {
+ var response = await Client.GetAsync("/api/unitofwork-test/CommitThenThrowAfterResponseFlush");
+ await response.Content.ReadAsStringAsync();
+ });
+
+ ServiceProvider.GetRequiredService()
+ .UowCompletedAfterResponseFlush.ShouldBe(true);
+ }
+
+ [Fact]
+ public async Task Repository_Access_After_Response_Flush_Runs_Outside_The_Request_Uow()
+ {
+ Options.CompleteUnitOfWorkOnResponseStarting = true;
+
+ var body = await GetResponseAsStringAsync("/api/unitofwork-test/ReadRepositoryAfterResponseFlush");
+ body.ShouldBe("before=ok(1);after=ok(1,ambient=null)");
+ }
+
+ [Fact]
+ public async Task Raw_Database_Provider_After_Response_Flush_Throws()
+ {
+ Options.CompleteUnitOfWorkOnResponseStarting = true;
+
+ var body = await GetResponseAsStringAsync("/api/unitofwork-test/RawDatabaseProviderAfterResponseFlush");
+ body.ShouldBe("first:threw-AbpException");
+ }
+
+ [Fact]
+ public async Task Response_Flush_Inside_Nested_Uow_Should_Not_Complete_The_Nested_Uow()
+ {
+ Options.CompleteUnitOfWorkOnResponseStarting = true;
+
+ var body = await GetResponseAsStringAsync("/api/unitofwork-test/NestedUowDuringResponseFlush");
+ body.ShouldBe("first:outer-not-completed:nested-completed-by-owner");
+ }
+
+ [Fact]
+ public async Task Completing_The_Uow_In_The_Action_Still_Fails_At_End_Of_Pipeline_By_Default()
+ {
+ var response = await Client.GetAsync("/api/unitofwork-test/CompleteCurrentUow");
+ response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
+ }
+
+ [Fact]
+ public async Task Opt_In_Url_Enables_The_Feature_For_A_Matching_Path()
+ {
+ Options.CompleteUnitOfWorkOnResponseStartingUrls.Add("/api/unitofwork-test/CommitBeforeResponseFlush");
+
+ var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
+ result.ShouldBe("first:completed");
+ }
+
+ [Fact]
+ public async Task Opt_In_Url_With_A_Trailing_Slash_Still_Matches()
+ {
+ Options.CompleteUnitOfWorkOnResponseStartingUrls.Add("/api/unitofwork-test/");
+
+ var result = await GetResponseAsStringAsync("/api/unitofwork-test/CommitBeforeResponseFlush");
+ result.ShouldBe("first:completed");
+ }
+
+ [Fact]
+ public async Task Response_Flush_Inside_A_Child_Uow_Scope_Should_Not_Complete_The_Request_Uow()
+ {
+ Options.CompleteUnitOfWorkOnResponseStarting = true;
+
+ // A child scope (Begin without requiresNew) shares the request unit of work, so completing
+ // it on response start would commit under the still-active scope; it is left to the end of
+ // the pipeline instead, like a nested (requiresNew) unit of work.
+ var body = await GetResponseAsStringAsync("/api/unitofwork-test/ChildUowDuringResponseFlush");
+ body.ShouldBe("first:request-not-completed");
+ }
+
+ [Fact]
+ public async Task An_Event_Handler_Starting_The_Response_During_The_End_Of_Pipeline_Completion_Should_Not_Fail()
+ {
+ Options.CompleteUnitOfWorkOnResponseStarting = true;
+
+ // The response does not start during the pipeline here, so the middleware completes the
+ // unit of work at its end; the event handler then starts the response from inside that
+ // completion. The OnStarting callback must not attempt a second completion (which would
+ // throw "Completion has already been requested for this unit of work").
+ var body = await GetResponseAsStringAsync("/api/unitofwork-test/PublishEventThatWritesResponseOnCompletion");
+ body.ShouldBe("event-written");
+ }
}
diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs
index ebf2c12a6a..77cf3f07b4 100644
--- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs
+++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Uow/UnitOfWorkTestController.cs
@@ -1,5 +1,14 @@
-using Microsoft.AspNetCore.Mvc;
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
using Shouldly;
+using Volo.Abp;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.EventBus.Local;
+using Volo.Abp.MemoryDb;
+using Volo.Abp.TestApp.MemoryDb;
+using Volo.Abp.TestApp.Domain;
using Volo.Abp.Uow;
namespace Volo.Abp.AspNetCore.Mvc.Uow;
@@ -64,4 +73,149 @@ public class UnitOfWorkTestController : AbpController
_testUnitOfWorkConfig.ThrowExceptionOnComplete = true;
}
+
+ [HttpGet]
+ [Route("CommitBeforeResponseFlush")]
+ public async Task CommitBeforeResponseFlush()
+ {
+ var uow = CurrentUnitOfWork;
+ uow.ShouldNotBeNull();
+
+ // Start the response from inside the pipeline, before the middleware would commit.
+ await Response.WriteAsync("first");
+ await Response.Body.FlushAsync();
+
+ await Response.WriteAsync(uow.IsCompleted ? ":completed" : ":not-completed");
+ }
+
+ [HttpGet]
+ [Route("CommitThenThrowAfterResponseFlush")]
+ public async Task CommitThenThrowAfterResponseFlush()
+ {
+ var uow = CurrentUnitOfWork;
+
+ await Response.WriteAsync("first");
+ await Response.Body.FlushAsync();
+
+ _testUnitOfWorkConfig.UowCompletedAfterResponseFlush = uow.IsCompleted;
+
+ throw new UserFriendlyException("boom after the response was already flushed");
+ }
+
+ [HttpGet]
+ [Route("ReadRepositoryAfterResponseFlush")]
+ public async Task ReadRepositoryAfterResponseFlush()
+ {
+ var repository = LazyServiceProvider.LazyGetRequiredService>();
+
+ var before = (await repository.GetListAsync()).Count;
+ await Response.WriteAsync($"before=ok({before})");
+ await Response.Body.FlushAsync();
+
+ string after;
+ try
+ {
+ var count = (await repository.GetListAsync()).Count;
+ after = $";after=ok({count},ambient={(UnitOfWorkManager.Current == null ? "null" : "present")})";
+ }
+ catch (Exception ex)
+ {
+ after = $";after=threw:{ex.GetType().Name}";
+ }
+
+ await Response.WriteAsync(after);
+ }
+
+ [HttpGet]
+ [Route("RawDatabaseProviderAfterResponseFlush")]
+ public async Task RawDatabaseProviderAfterResponseFlush()
+ {
+ var databaseProvider = LazyServiceProvider
+ .LazyGetRequiredService>();
+
+ await Response.WriteAsync("first");
+ await Response.Body.FlushAsync();
+
+ string outcome;
+ try
+ {
+ await databaseProvider.GetDatabaseAsync();
+ outcome = ":ok";
+ }
+ catch (AbpException)
+ {
+ // Raw provider access has no ambient uow once the response started, so it throws.
+ outcome = ":threw-AbpException";
+ }
+
+ await Response.WriteAsync(outcome);
+ }
+
+ [HttpGet]
+ [Route("NestedUowDuringResponseFlush")]
+ public async Task NestedUowDuringResponseFlush()
+ {
+ using (var nested = UnitOfWorkManager.Begin(requiresNew: true, isTransactional: false))
+ {
+ await Response.WriteAsync("first");
+ await Response.Body.FlushAsync();
+
+ // The outer request unit of work (nested.Outer) must not have been completed on response
+ // start while a nested unit of work is current.
+ await Response.WriteAsync(nested.Outer!.IsCompleted ? ":outer-completed" : ":outer-not-completed");
+
+ string outcome;
+ try
+ {
+ await nested.CompleteAsync();
+ outcome = ":nested-completed-by-owner";
+ }
+ catch (AbpException)
+ {
+ outcome = ":nested-already-completed";
+ }
+
+ await Response.WriteAsync(outcome);
+ }
+ }
+
+ [HttpGet]
+ [Route("CompleteCurrentUow")]
+ public async Task CompleteCurrentUow()
+ {
+ // Complete the request unit of work inside the action, without writing the response yet.
+ // The middleware must still try to complete it at the end of the pipeline (original behavior).
+ await CurrentUnitOfWork.CompleteAsync();
+ }
+
+ [HttpGet]
+ [Route("ChildUowDuringResponseFlush")]
+ public async Task ChildUowDuringResponseFlush()
+ {
+ var requestUow = CurrentUnitOfWork!;
+
+ using (UnitOfWorkManager.Begin())
+ {
+ await Response.WriteAsync("first");
+ await Response.Body.FlushAsync();
+
+ // The request unit of work must not be completed on response start while a child
+ // unit of work scope (begun without requiresNew) is still active over it.
+ await Response.WriteAsync(requestUow.IsCompleted ? ":request-completed" : ":request-not-completed");
+ }
+ }
+
+ [HttpGet]
+ [Route("PublishEventThatWritesResponseOnCompletion")]
+ public async Task PublishEventThatWritesResponseOnCompletion()
+ {
+ // Published inside the request unit of work, so the handler runs while the middleware is
+ // completing it at the end of the pipeline. The handler writes to the response, which starts
+ // it mid-completion; the middleware's OnStarting callback must not try to complete again.
+ await LazyServiceProvider.LazyGetRequiredService()
+ .PublishAsync(new ResponseWritingTestEvent());
+
+ // Ok() sets 200 without writing the body, so the response does not start inside the pipeline.
+ return Ok();
+ }
}
diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo.Abp.AspNetCore.Uow.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo.Abp.AspNetCore.Uow.Tests.csproj
new file mode 100644
index 0000000000..08b17d2002
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo.Abp.AspNetCore.Uow.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+
+ net10.0
+ Volo.Abp.AspNetCore.Uow.Tests
+ Volo.Abp.AspNetCore.Uow.Tests
+ true
+ false
+ false
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUowTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUowTestModule.cs
new file mode 100644
index 0000000000..df2256258f
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/AbpAspNetCoreUowTestModule.cs
@@ -0,0 +1,61 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Volo.Abp.AspNetCore.Mvc;
+using Volo.Abp.AspNetCore.TestBase;
+using Volo.Abp.Autofac;
+using Volo.Abp.Data;
+using Volo.Abp.EntityFrameworkCore;
+using Volo.Abp.EntityFrameworkCore.Sqlite;
+using Volo.Abp.Modularity;
+
+namespace Volo.Abp.AspNetCore.Uow;
+
+[DependsOn(
+ typeof(AbpAspNetCoreTestBaseModule),
+ typeof(AbpAspNetCoreMvcModule),
+ typeof(AbpEntityFrameworkCoreSqliteModule),
+ typeof(AbpAutofacModule)
+ )]
+public class AbpAspNetCoreUowTestModule : AbpModule
+{
+ private readonly AbpUnitTestSqliteDatabase _database = new AbpUnitTestSqliteDatabase();
+
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.AddAbpDbContext(options =>
+ {
+ options.AddDefaultRepositories(includeAllEntities: true);
+ });
+
+ Configure(options =>
+ {
+ options.ConnectionStrings.Default = _database.ConnectionString;
+ });
+
+ Configure(options =>
+ {
+ options.Configure(dbContext => dbContext.UseSqlite().AddAbpDbContextOptionsExtension());
+ });
+
+ _database.CreateTables(new UowVisibilityTestDbContext(
+ new DbContextOptionsBuilder()
+ .UseSqlite(_database.ConnectionString)
+ .AddAbpDbContextOptionsExtension()
+ .Options));
+ }
+
+ public override void OnApplicationInitialization(ApplicationInitializationContext context)
+ {
+ var app = context.GetApplicationBuilder();
+
+ app.UseRouting();
+ app.UseUnitOfWork();
+ app.UseConfiguredEndpoints();
+ }
+
+ public override void OnApplicationShutdown(ApplicationShutdownContext context)
+ {
+ _database.Dispose();
+ }
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/Program.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/Program.cs
new file mode 100644
index 0000000000..98ab220169
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/Program.cs
@@ -0,0 +1,15 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Hosting;
+using Volo.Abp.AspNetCore.TestBase;
+using Volo.Abp.AspNetCore.Uow;
+
+var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+{
+ EnvironmentName = Environments.Staging
+});
+
+await builder.RunAbpModuleAsync();
+
+public partial class Program
+{
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UnitOfWorkMiddleware_Relational_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UnitOfWorkMiddleware_Relational_Tests.cs
new file mode 100644
index 0000000000..2d20c60005
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UnitOfWorkMiddleware_Relational_Tests.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.AspNetCore.TestBase;
+using Xunit;
+
+namespace Volo.Abp.AspNetCore.Uow;
+
+public class UnitOfWorkMiddleware_Relational_Tests : AbpWebApplicationFactoryIntegratedTest
+{
+ private void EnableCompleteOnResponseStarting()
+ {
+ ServiceProvider.GetRequiredService>()
+ .Value.CompleteUnitOfWorkOnResponseStarting = true;
+ }
+
+ private async Task CountAsync(string name)
+ {
+ var response = await Client.GetAsync("/api/uow-visibility/count?name=" + name);
+ response.EnsureSuccessStatusCode();
+ return int.Parse(await response.Content.ReadAsStringAsync());
+ }
+
+ [Fact]
+ public async Task Row_Written_During_Request_Is_Visible_From_An_Independent_Connection_On_Response_Start()
+ {
+ EnableCompleteOnResponseStarting();
+
+ var response = await Client.GetAsync("/api/uow-visibility/insert-then-read");
+ response.EnsureSuccessStatusCode();
+ (await response.Content.ReadAsStringAsync()).ShouldBe("inserted:visible");
+ }
+
+ [Fact]
+ public async Task Row_Written_During_Request_Is_Still_Committed_When_The_Feature_Is_Disabled()
+ {
+ var name = Guid.NewGuid().ToString("N");
+
+ var insert = await Client.GetAsync("/api/uow-visibility/insert?name=" + name);
+ insert.EnsureSuccessStatusCode();
+
+ (await CountAsync(name)).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Exception_Before_Response_Rolls_Back_The_Written_Row()
+ {
+ EnableCompleteOnResponseStarting();
+ var name = Guid.NewGuid().ToString("N");
+
+ var insert = await Client.GetAsync("/api/uow-visibility/insert-then-throw?name=" + name);
+ insert.IsSuccessStatusCode.ShouldBeFalse();
+
+ (await CountAsync(name)).ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Committed_Row_Survives_An_Exception_Raised_After_The_Response_Started()
+ {
+ EnableCompleteOnResponseStarting();
+ var name = Guid.NewGuid().ToString("N");
+
+ await Should.ThrowAsync(async () =>
+ {
+ var response = await Client.GetAsync("/api/uow-visibility/insert-flush-then-throw?name=" + name);
+ await response.Content.ReadAsStringAsync();
+ });
+
+ (await CountAsync(name)).ShouldBe(1);
+ }
+ [Fact]
+ public async Task Committed_Row_Survives_A_Completed_Handler_Failing_On_Response_Start()
+ {
+ EnableCompleteOnResponseStarting();
+ var name = Guid.NewGuid().ToString("N");
+
+ // The handler's error must surface as-is (not masked by a second "already requested" completion);
+ // the row is committed regardless, since the handler runs after commit.
+ Exception surfaced = null;
+ try
+ {
+ var response = await Client.GetAsync("/api/uow-visibility/insert-flush-throwing-completed-handler?name=" + name);
+ await response.Content.ReadAsStringAsync();
+ }
+ catch (Exception ex)
+ {
+ surfaced = ex;
+ }
+
+ surfaced.ShouldNotBeNull();
+ surfaced.ToString().ShouldContain("boom in a completed handler");
+ surfaced.ToString().ShouldNotContain("already");
+
+ (await CountAsync(name)).ShouldBe(1);
+ }
+ [Fact]
+ public async Task A_Failing_Commit_On_Response_Start_Does_Not_Persist_Data()
+ {
+ EnableCompleteOnResponseStarting();
+ var name = Guid.NewGuid().ToString("N");
+
+ Exception surfaced = null;
+ try
+ {
+ var response = await Client.GetAsync("/api/uow-visibility/insert-then-fail-commit?name=" + name);
+ await response.Content.ReadAsStringAsync();
+ }
+ catch (Exception ex)
+ {
+ surfaced = ex;
+ }
+
+ // The commit fails on response start, so the request must surface an error and persist nothing.
+ surfaced.ShouldNotBeNull();
+ (await CountAsync(name)).ShouldBe(0);
+ }
+
+ [Fact]
+ public async Task Result_Serialization_Failure_Rolls_Back_And_Does_Not_Commit_On_The_Error_Response()
+ {
+ EnableCompleteOnResponseStarting();
+ var name = Guid.NewGuid().ToString("N");
+
+ HttpResponseMessage response = null;
+ Exception surfaced = null;
+ try
+ {
+ response = await Client.GetAsync("/api/uow-visibility/insert-then-throw-in-serialization?name=" + name);
+ await response.Content.ReadAsStringAsync();
+ }
+ catch (Exception ex)
+ {
+ surfaced = ex;
+ }
+
+ // The action ran and saved the row, then serializing the result failed. The request must therefore
+ // fail with a server error (not a 404 or a success), and the error response, written by the upstream
+ // exception middleware after the request unit of work is disposed, must not commit the failed request.
+ (surfaced != null || (response != null && (int)response.StatusCode >= 500)).ShouldBeTrue();
+ (await CountAsync(name)).ShouldBe(0);
+ }
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityController.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityController.cs
new file mode 100644
index 0000000000..ad0af449a2
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityController.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Volo.Abp;
+using Volo.Abp.AspNetCore.Mvc;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.Uow;
+
+namespace Volo.Abp.AspNetCore.Uow;
+
+[Route("api/uow-visibility")]
+public class UowVisibilityController : AbpController
+{
+ private readonly IRepository _repository;
+
+ public UowVisibilityController(IRepository repository)
+ {
+ _repository = repository;
+ }
+
+ [HttpGet]
+ [Route("insert-then-read")]
+ [UnitOfWork(isTransactional: true)]
+ public async Task InsertThenRead()
+ {
+ var name = Guid.NewGuid().ToString("N");
+ await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
+
+ await Response.WriteAsync("inserted");
+ await Response.Body.FlushAsync();
+
+ int count;
+ using (var uow = UnitOfWorkManager.Begin(requiresNew: true, isTransactional: false))
+ {
+ count = await _repository.CountAsync(x => x.Name == name);
+ await uow.CompleteAsync();
+ }
+
+ await Response.WriteAsync(count == 1 ? ":visible" : ":not-visible");
+ }
+
+ [HttpGet]
+ [Route("insert")]
+ [UnitOfWork(isTransactional: true)]
+ public async Task Insert(string name)
+ {
+ await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
+ await Response.WriteAsync("inserted");
+ }
+
+ [HttpGet]
+ [Route("count")]
+ public async Task Count(string name)
+ {
+ var count = await _repository.CountAsync(x => x.Name == name);
+ await Response.WriteAsync(count.ToString());
+ }
+
+ // Insert (autoSave sends the INSERT to the transaction) then throw before the response: must roll back.
+ [HttpGet]
+ [Route("insert-then-throw")]
+ [UnitOfWork(isTransactional: true)]
+ public async Task InsertThenThrow(string name)
+ {
+ await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name), autoSave: true);
+ throw new AbpException("boom before the response started");
+ }
+
+ // Insert, flush the response (committed here when enabled), then throw: the committed row survives.
+ [HttpGet]
+ [Route("insert-flush-then-throw")]
+ [UnitOfWork(isTransactional: true)]
+ public async Task InsertFlushThenThrow(string name)
+ {
+ await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
+
+ await Response.WriteAsync("inserted");
+ await Response.Body.FlushAsync();
+
+ throw new AbpException("boom after the response started");
+ }
+ // Insert, register a completed handler that throws (runs after commit), then flush.
+ [HttpGet]
+ [Route("insert-flush-throwing-completed-handler")]
+ [UnitOfWork(isTransactional: true)]
+ public async Task InsertFlushWithThrowingCompletedHandler(string name)
+ {
+ await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
+ CurrentUnitOfWork.OnCompleted(() => throw new AbpException("boom in a completed handler"));
+
+ await Response.WriteAsync("inserted");
+ await Response.Body.FlushAsync();
+ }
+ // Insert a valid row plus an invalid one (Name is required): the commit at response start fails.
+ [HttpGet]
+ [Route("insert-then-fail-commit")]
+ [UnitOfWork(isTransactional: true)]
+ public async Task InsertThenFailCommit(string name)
+ {
+ await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
+ await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), null));
+
+ await Response.WriteAsync("inserted");
+ await Response.Body.FlushAsync();
+ }
+
+ // The action succeeds (so the action filter saves changes), then serializing the object result throws
+ // before the response starts. The upstream exception middleware writes the error response after the
+ // request unit of work is disposed, so response-start completion must not commit the failed request.
+ [HttpGet]
+ [Route("insert-then-throw-in-serialization")]
+ [UnitOfWork(isTransactional: true)]
+ public async Task InsertThenThrowInSerialization(string name)
+ {
+ await _repository.InsertAsync(new UowVisibilityTestEntity(Guid.NewGuid(), name));
+ return Ok(new ThrowingOnSerializeDto());
+ }
+
+ public class ThrowingOnSerializeDto
+ {
+ public string Value => throw new AbpException("boom while serializing the object result");
+ }
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityTestEntity.cs b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityTestEntity.cs
new file mode 100644
index 0000000000..f3595ddd33
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Uow.Tests/Volo/Abp/AspNetCore/Uow/UowVisibilityTestEntity.cs
@@ -0,0 +1,46 @@
+using System;
+using Microsoft.EntityFrameworkCore;
+using Volo.Abp.Data;
+using Volo.Abp.Domain.Entities;
+using Volo.Abp.EntityFrameworkCore;
+using Volo.Abp.EntityFrameworkCore.Modeling;
+
+namespace Volo.Abp.AspNetCore.Uow;
+
+public class UowVisibilityTestEntity : AggregateRoot
+{
+ public string Name { get; set; }
+
+ protected UowVisibilityTestEntity()
+ {
+ }
+
+ public UowVisibilityTestEntity(Guid id, string name)
+ : base(id)
+ {
+ Name = name;
+ }
+}
+
+[ConnectionStringName("Default")]
+public class UowVisibilityTestDbContext : AbpDbContext
+{
+ public DbSet UowVisibilityTestEntities { get; set; }
+
+ public UowVisibilityTestDbContext(DbContextOptions options)
+ : base(options)
+ {
+ }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("UowVisibilityTestEntities");
+ b.ConfigureByConvention();
+ b.Property(x => x.Name).IsRequired();
+ });
+ }
+}
diff --git a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/AbpOpenIddictAspNetCoreModule.cs b/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/AbpOpenIddictAspNetCoreModule.cs
index 26729669e7..7e025a80b8 100644
--- a/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/AbpOpenIddictAspNetCoreModule.cs
+++ b/modules/openiddict/src/Volo.Abp.OpenIddict.AspNetCore/Volo/Abp/OpenIddict/AbpOpenIddictAspNetCoreModule.cs
@@ -1,10 +1,14 @@
-using Microsoft.AspNetCore.Identity;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using Volo.Abp.AspNetCore.MultiTenancy;
using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared;
+using Volo.Abp.AspNetCore.Uow;
using Volo.Abp.Modularity;
using Volo.Abp.OpenIddict.Globalization;
using Volo.Abp.OpenIddict.Scopes;
@@ -45,6 +49,37 @@ public class AbpOpenIddictAspNetCoreModule : AbpModule
{
options.RemoveClientIdClaim();
});
+
+ // Complete data written while processing OpenIddict requests before the response starts.
+ // Derived from the configured server endpoint paths so remapped endpoints are covered too.
+ context.Services.AddOptions()
+ .Configure>((uowOptions, serverOptions) =>
+ {
+ foreach (var path in GetServerEndpointPaths(serverOptions.Value))
+ {
+ uowOptions.CompleteUnitOfWorkOnResponseStartingUrls.AddIfNotContains(path);
+ }
+ });
+ }
+
+ private static IEnumerable GetServerEndpointPaths(OpenIddictServerOptions serverOptions)
+ {
+ var endpoints = serverOptions.TokenEndpointUris
+ .Concat(serverOptions.AuthorizationEndpointUris)
+ .Concat(serverOptions.DeviceAuthorizationEndpointUris)
+ .Concat(serverOptions.PushedAuthorizationEndpointUris)
+ .Concat(serverOptions.EndSessionEndpointUris)
+ .Concat(serverOptions.RevocationEndpointUris)
+ .Concat(serverOptions.EndUserVerificationEndpointUris);
+
+ foreach (var uri in endpoints)
+ {
+ var path = uri.IsAbsoluteUri ? uri.AbsolutePath : "/" + uri.OriginalString.TrimStart('/');
+ if (path.Length > 1)
+ {
+ yield return path;
+ }
+ }
}
private void AddOpenIddictServer(IServiceCollection services)
diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj
index f4c6815fd6..003b78894c 100644
--- a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj
+++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo.Abp.OpenIddict.AspNetCore.Tests.csproj
@@ -5,6 +5,8 @@
net10.0
+ true
+ true
@@ -16,6 +18,10 @@
+
+
+
+
diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenEndpoint_Integration_Tests.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenEndpoint_Integration_Tests.cs
new file mode 100644
index 0000000000..3f8fd1a855
--- /dev/null
+++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenEndpoint_Integration_Tests.cs
@@ -0,0 +1,69 @@
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Volo.Abp.AspNetCore.TestBase;
+using Volo.Abp.AspNetCore.Uow;
+using Xunit;
+
+namespace Volo.Abp.OpenIddict.Integration;
+
+// A real "/connect/token" (client_credentials) request through the OpenIddict server. A probe registered
+// outside UseUnitOfWork reads the token count from an independent connection at response start.
+public class OpenIddictTokenEndpoint_Integration_Tests : AbpWebApplicationFactoryIntegratedTest
+{
+ private AbpAspNetCoreUnitOfWorkOptions Options =>
+ ServiceProvider.GetRequiredService>().Value;
+
+ private long? TokenCountAtResponseStart =>
+ ServiceProvider.GetRequiredService().TokenCountAtResponseStart;
+
+ private Task RequestTokenAsync()
+ {
+ return Client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary
+ {
+ ["grant_type"] = "client_credentials",
+ ["client_id"] = "test-client",
+ ["client_secret"] = "test-secret"
+ }));
+ }
+
+ [Fact]
+ public async Task Token_Row_Is_Committed_Before_The_Connect_Token_Response_Is_Sent()
+ {
+ // The OpenIddict module opts its configured endpoint paths (including "/connect/token") in by default.
+ var response = await RequestTokenAsync();
+
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ (await response.Content.ReadAsStringAsync()).ShouldContain("access_token");
+ TokenCountAtResponseStart.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Without_The_Opt_In_The_Token_Is_Not_Committed_When_The_Response_Starts()
+ {
+ // Negative control: without the opt-in the token is committed only at the end of the pipeline,
+ // so the probe reads 0. This proves the positive case genuinely observes response-start timing.
+ Options.CompleteUnitOfWorkOnResponseStartingUrls.Clear();
+ Options.CompleteUnitOfWorkOnResponseStarting = false;
+
+ var response = await RequestTokenAsync();
+
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ TokenCountAtResponseStart.ShouldBe(0);
+ }
+
+ [Fact]
+ public void The_Configured_OpenIddict_Endpoint_Paths_Are_Opted_In()
+ {
+ // The opt-in list is derived from the configured server endpoints, so a non-"/connect" endpoint
+ // like "/device" is covered, and the custom "/my-custom/token" endpoint the test host registered
+ // is followed too - a hardcoded "/connect" prefix would miss both.
+ Options.CompleteUnitOfWorkOnResponseStartingUrls.ShouldContain("/connect/token");
+ Options.CompleteUnitOfWorkOnResponseStartingUrls.ShouldContain("/device");
+ Options.CompleteUnitOfWorkOnResponseStartingUrls.ShouldContain("/my-custom/token");
+ }
+}
diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs
new file mode 100644
index 0000000000..29e9c5a180
--- /dev/null
+++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/OpenIddictTokenIntegrationTestModule.cs
@@ -0,0 +1,157 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Mvc.ApplicationParts;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using OpenIddict.Abstractions;
+using OpenIddict.Server;
+using Volo.Abp.AspNetCore.TestBase;
+using Volo.Abp.AspNetCore.Uow;
+using Volo.Abp.Data;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.EntityFrameworkCore;
+using Volo.Abp.EntityFrameworkCore.Sqlite;
+using Volo.Abp.Modularity;
+using Volo.Abp.OpenIddict.Applications;
+using Volo.Abp.OpenIddict.EntityFrameworkCore;
+using Volo.Abp.OpenIddict.Tokens;
+using Volo.Abp.Uow;
+using Volo.Abp.Autofac;
+
+namespace Volo.Abp.OpenIddict.Integration;
+
+public class TokenVisibilityRecorder
+{
+ public long? TokenCountAtResponseStart { get; set; }
+}
+
+[DependsOn(
+ typeof(AbpAspNetCoreTestBaseModule),
+ typeof(AbpOpenIddictAspNetCoreModule),
+ typeof(AbpOpenIddictEntityFrameworkCoreModule),
+ typeof(AbpEntityFrameworkCoreSqliteModule),
+ typeof(AbpAutofacModule)
+ )]
+public class OpenIddictTokenIntegrationTestModule : AbpModule
+{
+ // File-based SQLite (not shared-cache in-memory) so an independent connection can read committed
+ // state while another holds an open write transaction, without the shared-cache single-writer deadlock.
+ private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"abp-oidc-uow-{Guid.NewGuid():N}.db");
+ private string ConnectionString => $"Data Source={_databasePath};Pooling=False";
+
+ public override void PreConfigureServices(ServiceConfigurationContext context)
+ {
+ PreConfigure(options =>
+ {
+ options.AddDevelopmentEncryptionAndSigningCertificate = false;
+ });
+
+ PreConfigure(builder =>
+ {
+ builder.AddEphemeralEncryptionKey();
+ builder.AddEphemeralSigningKey();
+ builder.UseAspNetCore().DisableTransportSecurityRequirement();
+ });
+ }
+
+ public override void ConfigureServices(ServiceConfigurationContext context)
+ {
+ context.Services.AddSingleton();
+
+ // A remapped token endpoint, so the tests can prove the opt-in list is derived from the configured
+ // server endpoints (custom endpoints are followed) rather than a hardcoded "/connect" prefix.
+ Configure(options =>
+ {
+ options.TokenEndpointUris.Add(new Uri("my-custom/token", UriKind.Relative));
+ });
+
+ // The OpenIddict controllers (including the token endpoint) live in a referenced assembly.
+ context.Services.GetSingletonInstance()
+ .ApplicationParts.AddIfNotContains(typeof(AbpOpenIddictAspNetCoreModule).Assembly);
+
+ using (var dbContext = new OpenIddictDbContext(
+ new DbContextOptionsBuilder().UseSqlite(ConnectionString).Options))
+ {
+ dbContext.Database.EnsureCreated();
+ }
+
+ Configure(options =>
+ {
+ options.ConnectionStrings.Default = ConnectionString;
+ });
+
+ Configure(options =>
+ {
+ options.Configure(c => c.UseSqlite());
+ });
+ }
+
+ public override void OnApplicationInitialization(ApplicationInitializationContext context)
+ {
+ SeedClientAsync(context.ServiceProvider).GetAwaiter().GetResult();
+
+ var app = context.GetApplicationBuilder();
+ app.UseRouting();
+
+ // Registered before UseUnitOfWork so its OnStarting runs after the unit of work commits
+ // (callbacks run in reverse order): reads the token count from an independent connection.
+ app.Use(async (ctx, next) =>
+ {
+ if (ctx.Request.Path.StartsWithSegments("/connect/token"))
+ {
+ ctx.Response.OnStarting(async () =>
+ {
+ var recorder = ctx.RequestServices.GetRequiredService();
+ var uowManager = ctx.RequestServices.GetRequiredService();
+ using var uow = uowManager.Begin(requiresNew: true, isTransactional: false);
+ var repository = ctx.RequestServices.GetRequiredService>();
+ recorder.TokenCountAtResponseStart = await repository.GetCountAsync();
+ await uow.CompleteAsync();
+ });
+ }
+
+ await next();
+ });
+
+ app.UseAuthentication();
+ app.UseUnitOfWork();
+ app.UseAuthorization();
+ app.UseConfiguredEndpoints();
+ }
+
+ public override void OnApplicationShutdown(ApplicationShutdownContext context)
+ {
+ if (File.Exists(_databasePath))
+ {
+ File.Delete(_databasePath);
+ }
+ }
+
+ private static async Task SeedClientAsync(IServiceProvider serviceProvider)
+ {
+ using var scope = serviceProvider.CreateScope();
+ var uowManager = scope.ServiceProvider.GetRequiredService();
+ using var uow = uowManager.Begin();
+
+ var applicationManager = scope.ServiceProvider.GetRequiredService();
+ if (await applicationManager.FindByClientIdAsync("test-client") == null)
+ {
+ await applicationManager.CreateAsync(new AbpApplicationDescriptor
+ {
+ ClientId = "test-client",
+ ClientSecret = "test-secret",
+ DisplayName = "Test Client",
+ ClientType = OpenIddictConstants.ClientTypes.Confidential,
+ Permissions =
+ {
+ OpenIddictConstants.Permissions.Endpoints.Token,
+ OpenIddictConstants.Permissions.GrantTypes.ClientCredentials
+ }
+ });
+ }
+
+ await uow.CompleteAsync();
+ }
+}
diff --git a/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/Program.cs b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/Program.cs
new file mode 100644
index 0000000000..a2ac64130a
--- /dev/null
+++ b/modules/openiddict/test/Volo.Abp.OpenIddict.AspNetCore.Tests/Volo/Abp/OpenIddict/Integration/Program.cs
@@ -0,0 +1,15 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Hosting;
+using Volo.Abp.AspNetCore.TestBase;
+using Volo.Abp.OpenIddict.Integration;
+
+var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+{
+ EnvironmentName = Environments.Staging
+});
+
+await builder.RunAbpModuleAsync();
+
+public partial class Program
+{
+}