Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,43 @@ void M()
return VerifyGeneratorOutput(source, [externalRef]);
}

// Regression for https://github.com/thomhurst/TUnit/issues/5455 — public virtual properties
// whose setters are individually inaccessible (internal/private) must emit getter-only overrides.
// `Reason` is the control: `protected internal set` is reachable via the mock's inheritance, so
// its setter must still be emitted.
[Test]
public Task Partial_Mock_Omits_Inaccessible_Property_Setters()
{
var externalSource = """
namespace ExternalLib
{
public class ExternalResponse
{
public virtual int StatusCode { get; internal set; }
public virtual bool IsSuccess { get; private set; }
public virtual string Reason { get; protected internal set; } = "";
}
}
""";

var externalRef = CreateExternalAssemblyReference(externalSource);

var source = """
using TUnit.Mocks;
using ExternalLib;

public class TestUsage
{
void M()
{
var mock = Mock.Of<ExternalResponse>();
}
}
""";

return VerifyGeneratorOutput(source, [externalRef]);
}

[Test]
public Task Partial_Mock_Filters_Members_With_Internal_Signature_Types()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// <auto-generated/>
#nullable enable

namespace TUnit.Mocks.Generated.ExternalLib
{
file sealed class ExternalResponseMockImpl : global::ExternalLib.ExternalResponse, global::TUnit.Mocks.IRaisable, global::TUnit.Mocks.IMockObject
{
private readonly global::TUnit.Mocks.MockEngine<global::ExternalLib.ExternalResponse> _engine;

[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
global::TUnit.Mocks.IMock? global::TUnit.Mocks.IMockObject.MockWrapper { get; set; }

internal ExternalResponseMockImpl(global::TUnit.Mocks.MockEngine<global::ExternalLib.ExternalResponse> engine) : base()
{
_engine = engine;
}

public override int StatusCode
{
get
{
if (_engine.TryHandleCallWithReturn<int>(0, "get_StatusCode", global::System.Array.Empty<object?>(), default, out var __result))
{
return __result;
}
return base.StatusCode;
}
}

public override bool IsSuccess
{
get
{
if (_engine.TryHandleCallWithReturn<bool>(1, "get_IsSuccess", global::System.Array.Empty<object?>(), default, out var __result))
{
return __result;
}
return base.IsSuccess;
}
}

public override string Reason
{
get
{
if (_engine.TryHandleCallWithReturn<string>(2, "get_Reason", global::System.Array.Empty<object?>(), "", out var __result))
{
return __result;
}
return base.Reason;
}
set
{
if (!_engine.TryHandleCall(3, "set_Reason", new object?[] { value }))
{
base.Reason = value;
}
}
}

[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]
public void RaiseEvent(string eventName, object? args)
{
throw new global::System.InvalidOperationException($"No event named '{eventName}' exists on this mock.");
}
}

file static class ExternalResponsePartialMockFactory
{
[global::System.Runtime.CompilerServices.ModuleInitializer]
internal static void Register()
{
global::TUnit.Mocks.MockRegistry.RegisterFactory<global::ExternalLib.ExternalResponse>(Create);
}

private static global::TUnit.Mocks.Mock<global::ExternalLib.ExternalResponse> Create(global::TUnit.Mocks.MockBehavior behavior, object[] constructorArgs)
{
var engine = new global::TUnit.Mocks.MockEngine<global::ExternalLib.ExternalResponse>(behavior);
var impl = new ExternalResponseMockImpl(engine);
engine.Raisable = impl;
var mock = new global::TUnit.Mocks.Mock<global::ExternalLib.ExternalResponse>(impl, engine);
return mock;
}
}
}


// ===== FILE SEPARATOR =====

// <auto-generated/>
#nullable enable

namespace TUnit.Mocks.Generated
{
public static class ExternalLib_ExternalResponse_MockMemberExtensions
{
extension(global::TUnit.Mocks.Mock<global::ExternalLib.ExternalResponse> mock)
{
public global::TUnit.Mocks.PropertyMockCall<int> StatusCode
=> new(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 0, 0, "StatusCode", true, false);

public global::TUnit.Mocks.PropertyMockCall<bool> IsSuccess
=> new(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 1, 0, "IsSuccess", true, false);

public global::TUnit.Mocks.PropertyMockCall<string> Reason
=> new(global::TUnit.Mocks.MockRegistry.GetEngine(mock), 2, 3, "Reason", true, true);
}
}
}


// ===== FILE SEPARATOR =====

// <auto-generated/>
#nullable enable

namespace TUnit.Mocks.Generated;
73 changes: 46 additions & 27 deletions TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,13 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
{
if (existingIndex.HasValue)
{
MergePropertyAccessors(properties, existingIndex.Value, property, ref memberIdCounter);
MergePropertyAccessors(properties, existingIndex.Value, property, ref memberIdCounter, compilationAssembly);
}
}
else
{
seenProperties[key] = properties.Count;
properties.Add(CreatePropertyModel(property, ref memberIdCounter, explicitInterfaceName, interfaceFqn));
properties.Add(CreatePropertyModel(property, ref memberIdCounter, explicitInterfaceName, interfaceFqn, compilationAssembly));
}
break;
}
Expand All @@ -93,13 +93,13 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
{
if (existingIndex.HasValue)
{
MergePropertyAccessors(properties, existingIndex.Value, indexer, ref memberIdCounter);
MergePropertyAccessors(properties, existingIndex.Value, indexer, ref memberIdCounter, compilationAssembly);
}
}
else
{
seenProperties[key] = properties.Count;
properties.Add(CreateIndexerModel(indexer, ref memberIdCounter, explicitInterfaceName, interfaceFqn));
properties.Add(CreateIndexerModel(indexer, ref memberIdCounter, explicitInterfaceName, interfaceFqn, compilationAssembly));
}
break;
}
Expand Down Expand Up @@ -179,13 +179,13 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
{
if (existingIndex.HasValue)
{
MergePropertyAccessors(properties, existingIndex.Value, property, ref memberIdCounter);
MergePropertyAccessors(properties, existingIndex.Value, property, ref memberIdCounter, compilationAssembly);
}
}
else
{
seenProperties[key] = properties.Count;
properties.Add(CreatePropertyModel(property, ref memberIdCounter, null, declaringInterfaceName: interfaceFqn));
properties.Add(CreatePropertyModel(property, ref memberIdCounter, null, declaringInterfaceName: interfaceFqn, compilationAssembly: compilationAssembly));
}
break;
}
Expand All @@ -198,13 +198,13 @@ public static (EquatableArray<MockMemberModel> Methods, EquatableArray<MockMembe
{
if (existingIndex.HasValue)
{
MergePropertyAccessors(properties, existingIndex.Value, indexer, ref memberIdCounter);
MergePropertyAccessors(properties, existingIndex.Value, indexer, ref memberIdCounter, compilationAssembly);
}
}
else
{
seenProperties[key] = properties.Count;
properties.Add(CreateIndexerModel(indexer, ref memberIdCounter, null, declaringInterfaceName: interfaceFqn));
properties.Add(CreateIndexerModel(indexer, ref memberIdCounter, null, declaringInterfaceName: interfaceFqn, compilationAssembly: compilationAssembly));
}
break;
}
Expand Down Expand Up @@ -292,13 +292,13 @@ private static void ProcessClassMembers(
{
if (existingIndex.HasValue)
{
MergePropertyAccessors(properties, existingIndex.Value, property, ref memberIdCounter);
MergePropertyAccessors(properties, existingIndex.Value, property, ref memberIdCounter, compilationAssembly);
}
}
else
{
seenProperties[key] = properties.Count;
properties.Add(CreatePropertyModel(property, ref memberIdCounter, null));
properties.Add(CreatePropertyModel(property, ref memberIdCounter, null, compilationAssembly: compilationAssembly));
}
}
else if (!seenProperties.ContainsKey(key))
Expand All @@ -318,13 +318,13 @@ private static void ProcessClassMembers(
{
if (existingIndex.HasValue)
{
MergePropertyAccessors(properties, existingIndex.Value, indexer, ref memberIdCounter);
MergePropertyAccessors(properties, existingIndex.Value, indexer, ref memberIdCounter, compilationAssembly);
}
}
else
{
seenProperties[key] = properties.Count;
properties.Add(CreateIndexerModel(indexer, ref memberIdCounter, null));
properties.Add(CreateIndexerModel(indexer, ref memberIdCounter, null, compilationAssembly: compilationAssembly));
}
}
else if (!seenProperties.ContainsKey(key))
Expand Down Expand Up @@ -362,9 +362,12 @@ private static void ProcessClassMembers(
private static bool IsMemberAccessibleFromExternal(ISymbol member, IAssemblySymbol compilationAssembly, bool hasInternalAccess)
{
var accessibility = member.DeclaredAccessibility;
// Private: never accessible from another assembly (no InternalsVisibleTo equivalent for private).
// ProtectedOrInternal (protected internal) is intentionally NOT blocked here:
// the generated mock subclasses the target, so the protected part grants access.
// ProtectedAndInternal (private protected) requires BOTH inheritance AND internal access.
if (accessibility == Accessibility.Private)
return false;
if (accessibility is Accessibility.Internal or Accessibility.ProtectedAndInternal)
{
if (!hasInternalAccess)
Expand All @@ -376,7 +379,7 @@ private static bool IsMemberAccessibleFromExternal(ISymbol member, IAssemblySymb

/// <summary>
/// Full accessibility check for members where the containing assembly isn't pre-computed
/// (used by DiscoverConstructors).
/// (used by DiscoverConstructors and IsAccessorAccessible).
/// </summary>
private static bool IsMemberAccessible(ISymbol member, IAssemblySymbol? compilationAssembly)
{
Expand All @@ -387,6 +390,9 @@ private static bool IsMemberAccessible(ISymbol member, IAssemblySymbol? compilat
return true;

var accessibility = member.DeclaredAccessibility;
// Private: never reachable cross-assembly (no InternalsVisibleTo equivalent for private).
if (accessibility == Accessibility.Private)
return false;
if (accessibility is Accessibility.Internal or Accessibility.ProtectedAndInternal)
{
if (!memberAssembly.GivesAccessTo(compilationAssembly))
Expand Down Expand Up @@ -532,27 +538,38 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m
/// accessors so the generated class satisfies all interfaces.
/// </summary>
private static void MergePropertyAccessors(List<MockMemberModel> properties, int existingIndex,
IPropertySymbol newProperty, ref int memberIdCounter)
IPropertySymbol newProperty, ref int memberIdCounter, IAssemblySymbol? compilationAssembly = null)
{
var existing = properties[existingIndex];
var needsGetter = !existing.HasGetter && newProperty.GetMethod is not null;
var needsSetter = !existing.HasSetter && newProperty.SetMethod is not null;
var newGetterAccessible = IsAccessorAccessible(newProperty.GetMethod, compilationAssembly);
var newSetterAccessible = IsAccessorAccessible(newProperty.SetMethod, compilationAssembly);
var needsGetter = !existing.HasGetter && newGetterAccessible;
var needsSetter = !existing.HasSetter && newSetterAccessible;

if (!needsGetter && !needsSetter) return;

properties[existingIndex] = existing with
{
HasGetter = existing.HasGetter || newProperty.GetMethod is not null,
HasSetter = existing.HasSetter || newProperty.SetMethod is not null,
HasGetter = existing.HasGetter || newGetterAccessible,
HasSetter = existing.HasSetter || newSetterAccessible,
SetterMemberId = existing.HasSetter ? existing.SetterMemberId
: newProperty.SetMethod is not null ? memberIdCounter++ : existing.SetterMemberId
: newSetterAccessible ? memberIdCounter++ : existing.SetterMemberId
};
}

private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref int memberIdCounter, string? explicitInterfaceName, string? declaringInterfaceName = null)
/// <summary>
/// Returns true if the accessor exists AND is accessible from the compilation assembly.
/// Needed because e.g. `internal set` on an external type exists in the symbol but can't be overridden.
/// </summary>
private static bool IsAccessorAccessible(IMethodSymbol? accessor, IAssemblySymbol? compilationAssembly)
=> accessor is not null && IsMemberAccessible(accessor, compilationAssembly);

private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref int memberIdCounter, string? explicitInterfaceName, string? declaringInterfaceName = null, IAssemblySymbol? compilationAssembly = null)
{
var hasGetter = IsAccessorAccessible(property.GetMethod, compilationAssembly);
var hasSetter = IsAccessorAccessible(property.SetMethod, compilationAssembly);
var getterId = memberIdCounter++;
var setterId = property.SetMethod is not null ? memberIdCounter++ : 0;
var setterId = hasSetter ? memberIdCounter++ : 0;

return new MockMemberModel
{
Expand All @@ -563,8 +580,8 @@ private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref
IsVoid = false,
IsAsync = false,
IsProperty = true,
HasGetter = property.GetMethod is not null,
HasSetter = property.SetMethod is not null,
HasGetter = hasGetter,
HasSetter = hasSetter,
SetterMemberId = setterId,
ExplicitInterfaceName = explicitInterfaceName,
DeclaringInterfaceName = declaringInterfaceName,
Expand Down Expand Up @@ -613,10 +630,12 @@ public static EquatableArray<MockConstructorModel> DiscoverConstructors(INamedTy
return new EquatableArray<MockConstructorModel>(constructors.ToImmutableArray());
}

private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref int memberIdCounter, string? explicitInterfaceName, string? declaringInterfaceName = null)
private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref int memberIdCounter, string? explicitInterfaceName, string? declaringInterfaceName = null, IAssemblySymbol? compilationAssembly = null)
{
var hasGetter = IsAccessorAccessible(indexer.GetMethod, compilationAssembly);
var hasSetter = IsAccessorAccessible(indexer.SetMethod, compilationAssembly);
var getterId = memberIdCounter++;
var setterId = indexer.SetMethod is not null ? memberIdCounter++ : 0;
var setterId = hasSetter ? memberIdCounter++ : 0;

return new MockMemberModel
{
Expand All @@ -629,8 +648,8 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i
IsAsync = false,
IsProperty = true,
IsIndexer = true,
HasGetter = indexer.GetMethod is not null,
HasSetter = indexer.SetMethod is not null,
HasGetter = hasGetter,
HasSetter = hasSetter,
Parameters = new EquatableArray<MockParameterModel>(
indexer.Parameters.Select(p => new MockParameterModel
{
Expand Down
16 changes: 16 additions & 0 deletions TUnit.Mocks.Tests/Issue5455Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Azure;

namespace TUnit.Mocks.Tests;

// Regression: https://github.com/thomhurst/TUnit/issues/5455
// Azure.Response.IsError is `public virtual bool IsError { get; internal set; }` — the internal
// setter is invisible to external assemblies, so the generated override must not emit it.
public class Issue5455Tests
{
[Test]
public void Mocking_Response_With_Internal_Setter_Compiles()
{
var mock = Mock.Of<Response>(MockBehavior.Strict);
_ = mock.Object;
}
}
Loading