From 22cbb8a56b8834377c1608d9125d932bbafb691d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Valentin=20Breu=C3=9F?= <vbreuss@gmail.com>
Date: Sun, 22 Jun 2025 07:45:37 +0200
Subject: [PATCH] fix: throw `InvalidOperationException` when using async void
 delegates

The using an `async void` delegate, it is not possible to await the outcome. Therefore, it now throws an `InvalidOperationException` in this case.
---
 .../Core/Sources/DelegateSource.cs            | 32 +++++++++-
 Source/aweXpect.Core/Expect.cs                |  2 +-
 .../ThatTests/DelegateTests.cs                | 58 +++++++++++++++++++
 3 files changed, 88 insertions(+), 4 deletions(-)

diff --git a/Source/aweXpect.Core/Core/Sources/DelegateSource.cs b/Source/aweXpect.Core/Core/Sources/DelegateSource.cs
index 36d26d60c..e37319287 100644
--- a/Source/aweXpect.Core/Core/Sources/DelegateSource.cs
+++ b/Source/aweXpect.Core/Core/Sources/DelegateSource.cs
@@ -1,18 +1,34 @@
 ﻿using System;
+using System.Reflection;
+using System.Runtime.CompilerServices;
 using System.Threading;
 using System.Threading.Tasks;
 using aweXpect.Core.TimeSystem;
 
 namespace aweXpect.Core.Sources;
 
-internal class DelegateSource(Action<CancellationToken>? action) : IValueSource<DelegateValue>
+internal class DelegateSource : IValueSource<DelegateValue>
 {
+	private readonly Action<CancellationToken>? _action;
+
+	public DelegateSource(Action<CancellationToken>? action)
+	{
+		ThrowIfAsyncVoid(action?.GetMethodInfo(), "Func<CancellationToken, Task>");
+		_action = action;
+	}
+
+	public DelegateSource(Action? action)
+	{
+		ThrowIfAsyncVoid(action?.GetMethodInfo(), "Func<Task>");
+		_action = action is null ? null : _ => action();
+	}
+
 	#region IValueSource<DelegateValue> Members
 
 	public Task<DelegateValue> GetValue(ITimeSystem timeSystem,
 		CancellationToken cancellationToken)
 	{
-		if (action is null)
+		if (_action is null)
 		{
 			return Task.FromResult(new DelegateValue(null, TimeSpan.Zero, true));
 		}
@@ -21,7 +37,7 @@ public Task<DelegateValue> GetValue(ITimeSystem timeSystem,
 		try
 		{
 			sw.Start();
-			action(cancellationToken);
+			_action(cancellationToken);
 			sw.Stop();
 			return Task.FromResult(new DelegateValue(null, sw.Elapsed));
 		}
@@ -32,4 +48,14 @@ public Task<DelegateValue> GetValue(ITimeSystem timeSystem,
 	}
 
 	#endregion
+
+	private static void ThrowIfAsyncVoid(MethodInfo? method, string replaceType)
+	{
+		if (method is not null &&
+		    Attribute.IsDefined(method, typeof(AsyncStateMachineAttribute), true))
+		{
+			throw new InvalidOperationException(
+				$"Cannot use aweXpect on an async void method: Use {replaceType} instead.");
+		}
+	}
 }
diff --git a/Source/aweXpect.Core/Expect.cs b/Source/aweXpect.Core/Expect.cs
index ab65122b6..3e26cc57e 100644
--- a/Source/aweXpect.Core/Expect.cs
+++ b/Source/aweXpect.Core/Expect.cs
@@ -55,7 +55,7 @@ public static ThatDelegate.WithoutValue That(Action @delegate,
 		[CallerArgumentExpression("delegate")] string doNotPopulateThisValue = "")
 		=> new(new ExpectationBuilder<DelegateValue>(
 			// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
-			new DelegateSource(@delegate is null ? null : _ => @delegate()), doNotPopulateThisValue));
+			new DelegateSource(@delegate), doNotPopulateThisValue));
 
 	/// <summary>
 	///     Specify expectations for the current <see cref="Action{CancellationToken}" /> <paramref name="delegate" />.
diff --git a/Tests/aweXpect.Internal.Tests/ThatTests/DelegateTests.cs b/Tests/aweXpect.Internal.Tests/ThatTests/DelegateTests.cs
index c8683e45d..924167357 100644
--- a/Tests/aweXpect.Internal.Tests/ThatTests/DelegateTests.cs
+++ b/Tests/aweXpect.Internal.Tests/ThatTests/DelegateTests.cs
@@ -5,6 +5,64 @@ namespace aweXpect.Internal.Tests.ThatTests;
 
 public sealed class DelegateTests
 {
+	[Fact]
+	public async Task ForAsyncVoidAction_WhenVerifyingDoesNotThrow_ShouldThrowInvalidOperationException()
+	{
+		Task incompleteTask = new TaskCompletionSource<bool>().Task;
+		// ReSharper disable once AsyncVoidLambda
+		Action @delegate = async () => await incompleteTask;
+
+		async Task Act()
+			=> await That(@delegate).DoesNotThrow();
+
+		await That(Act).Throws<InvalidOperationException>()
+			.WithMessage("Cannot use aweXpect on an async void method: Use Func<Task> instead.");
+	}
+
+	[Fact]
+	public async Task
+		ForAsyncVoidAction_WithCancellationToken_WhenVerifyingDoesNotThrow_ShouldThrowInvalidOperationException()
+	{
+		Task incompleteTask = new TaskCompletionSource<bool>().Task;
+		// ReSharper disable once AsyncVoidLambda
+		Action<CancellationToken> @delegate = async _ => await incompleteTask;
+
+		async Task Act()
+			=> await That(@delegate).DoesNotThrow();
+
+		await That(Act).Throws<InvalidOperationException>()
+			.WithMessage("Cannot use aweXpect on an async void method: Use Func<CancellationToken, Task> instead.");
+	}
+
+	[Fact]
+	public async Task ForAsyncVoidAction_WhenVerifyingDoesThrow_ShouldThrowInvalidOperationException()
+	{
+		Task incompleteTask = new TaskCompletionSource<bool>().Task;
+		// ReSharper disable once AsyncVoidLambda
+		Action @delegate = async () => await incompleteTask;
+
+		async Task Act()
+			=> await That(@delegate).ThrowsException();
+
+		await That(Act).Throws<InvalidOperationException>()
+			.WithMessage("Cannot use aweXpect on an async void method: Use Func<Task> instead.");
+	}
+
+	[Fact]
+	public async Task
+		ForAsyncVoidAction_WithCancellationToken_WhenVerifyingDoesThrow_ShouldThrowInvalidOperationException()
+	{
+		Task incompleteTask = new TaskCompletionSource<bool>().Task;
+		// ReSharper disable once AsyncVoidLambda
+		Action<CancellationToken> @delegate = async _ => await incompleteTask;
+
+		async Task Act()
+			=> await That(@delegate).ThrowsException();
+
+		await That(Act).Throws<InvalidOperationException>()
+			.WithMessage("Cannot use aweXpect on an async void method: Use Func<CancellationToken, Task> instead.");
+	}
+
 	[Theory]
 	[AutoData]
 	public async Task ShouldReturnValue_FuncTaskValue(int value)
