diff --git a/CodingWithCalvin.Otel4Vsix.slnx b/CodingWithCalvin.Otel4Vsix.slnx index 8a7a973..e7e5385 100644 --- a/CodingWithCalvin.Otel4Vsix.slnx +++ b/CodingWithCalvin.Otel4Vsix.slnx @@ -1,3 +1,8 @@ - + + + + + + diff --git a/src/CodingWithCalvin.Otel4Vsix/Properties/AssemblyInfo.cs b/src/CodingWithCalvin.Otel4Vsix/Properties/AssemblyInfo.cs index 210de9c..d4a7838 100644 --- a/src/CodingWithCalvin.Otel4Vsix/Properties/AssemblyInfo.cs +++ b/src/CodingWithCalvin.Otel4Vsix/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System; using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; [assembly: AssemblyTitle("Otel4Vsix")] @@ -19,3 +20,5 @@ [assembly: AssemblyVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyInformationalVersion("1.0.0")] + +[assembly: InternalsVisibleTo("CodingWithCalvin.Otel4Vsix.Tests")] diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/ActivitySourceProviderTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/ActivitySourceProviderTests.cs new file mode 100644 index 0000000..949fe69 --- /dev/null +++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/ActivitySourceProviderTests.cs @@ -0,0 +1,246 @@ +using System; +using System.Diagnostics; +using FluentAssertions; +using Otel4Vsix.Tracing; +using Xunit; + +namespace CodingWithCalvin.Otel4Vsix.Tests +{ + public class ActivitySourceProviderTests : IDisposable + { + private ActivitySourceProvider _provider; + private ActivityListener _listener; + + public ActivitySourceProviderTests() + { + // Set up an activity listener so activities are actually created + _listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(_listener); + } + + public void Dispose() + { + _provider?.Dispose(); + _listener?.Dispose(); + } + + [Fact] + public void Constructor_WithNullServiceName_ThrowsArgumentNullException() + { + Action act = () => new ActivitySourceProvider(null, "1.0.0"); + + act.Should().Throw() + .And.ParamName.Should().Be("serviceName"); + } + + [Fact] + public void Constructor_WithEmptyServiceName_ThrowsArgumentNullException() + { + Action act = () => new ActivitySourceProvider(string.Empty, "1.0.0"); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithWhitespaceServiceName_ThrowsArgumentNullException() + { + Action act = () => new ActivitySourceProvider(" ", "1.0.0"); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithValidName_CreatesActivitySource() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + _provider.ActivitySource.Should().NotBeNull(); + _provider.ActivitySource.Name.Should().Be("TestService"); + _provider.ActivitySource.Version.Should().Be("1.0.0"); + } + + [Fact] + public void Constructor_WithNullVersion_UsesDefaultVersion() + { + _provider = new ActivitySourceProvider("TestService", null); + + _provider.ActivitySource.Should().NotBeNull(); + _provider.ActivitySource.Version.Should().Be("1.0.0"); + } + + [Fact] + public void ActivitySource_BeforeDispose_ReturnsActivitySource() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + var activitySource = _provider.ActivitySource; + + activitySource.Should().NotBeNull(); + } + + [Fact] + public void ActivitySource_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + _provider.Dispose(); + + Action act = () => { var _ = _provider.ActivitySource; }; + + act.Should().Throw() + .And.ObjectName.Should().Be(nameof(ActivitySourceProvider)); + } + + [Fact] + public void StartActivity_CreatesActivity() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + using var activity = _provider.StartActivity("TestActivity"); + + activity.Should().NotBeNull(); + activity.OperationName.Should().Be("TestActivity"); + } + + [Fact] + public void StartActivity_WithKind_SetsActivityKind() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + using var activity = _provider.StartActivity("TestActivity", ActivityKind.Client); + + activity.Should().NotBeNull(); + activity.Kind.Should().Be(ActivityKind.Client); + } + + [Fact] + public void StartActivity_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + _provider.Dispose(); + + Action act = () => _provider.StartActivity("TestActivity"); + + act.Should().Throw(); + } + + [Fact] + public void StartCommandActivity_CreatesActivityWithCorrectName() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + using var activity = _provider.StartCommandActivity("MyCommand"); + + activity.Should().NotBeNull(); + activity.OperationName.Should().Be("Command: MyCommand"); + } + + [Fact] + public void StartCommandActivity_SetsCommandNameTag() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + using var activity = _provider.StartCommandActivity("MyCommand"); + + activity.Should().NotBeNull(); + activity.GetTagItem("vs.command.name").Should().Be("MyCommand"); + } + + [Fact] + public void StartCommandActivity_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + _provider.Dispose(); + + Action act = () => _provider.StartCommandActivity("MyCommand"); + + act.Should().Throw(); + } + + [Fact] + public void StartToolWindowActivity_CreatesActivityWithCorrectName() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + using var activity = _provider.StartToolWindowActivity("SolutionExplorer", "Open"); + + activity.Should().NotBeNull(); + activity.OperationName.Should().Be("ToolWindow: SolutionExplorer.Open"); + } + + [Fact] + public void StartToolWindowActivity_SetsCorrectTags() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + using var activity = _provider.StartToolWindowActivity("SolutionExplorer", "Open"); + + activity.Should().NotBeNull(); + activity.GetTagItem("vs.toolwindow.name").Should().Be("SolutionExplorer"); + activity.GetTagItem("vs.toolwindow.operation").Should().Be("Open"); + } + + [Fact] + public void StartToolWindowActivity_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + _provider.Dispose(); + + Action act = () => _provider.StartToolWindowActivity("Window", "Op"); + + act.Should().Throw(); + } + + [Fact] + public void StartDocumentActivity_CreatesActivityWithCorrectName() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + using var activity = _provider.StartDocumentActivity("C:\\file.cs", "Open"); + + activity.Should().NotBeNull(); + activity.OperationName.Should().Be("Document: Open"); + } + + [Fact] + public void StartDocumentActivity_SetsCorrectTags() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + using var activity = _provider.StartDocumentActivity("C:\\file.cs", "Save"); + + activity.Should().NotBeNull(); + activity.GetTagItem("vs.document.path").Should().Be("C:\\file.cs"); + activity.GetTagItem("vs.document.operation").Should().Be("Save"); + } + + [Fact] + public void StartDocumentActivity_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + _provider.Dispose(); + + Action act = () => _provider.StartDocumentActivity("file.cs", "Open"); + + act.Should().Throw(); + } + + [Fact] + public void Dispose_MultipleTimes_DoesNotThrow() + { + _provider = new ActivitySourceProvider("TestService", "1.0.0"); + + Action act = () => + { + _provider.Dispose(); + _provider.Dispose(); + _provider.Dispose(); + }; + + act.Should().NotThrow(); + } + } +} diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/CodingWithCalvin.Otel4Vsix.Tests.csproj b/tests/CodingWithCalvin.Otel4Vsix.Tests/CodingWithCalvin.Otel4Vsix.Tests.csproj new file mode 100644 index 0000000..133ed98 --- /dev/null +++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/CodingWithCalvin.Otel4Vsix.Tests.csproj @@ -0,0 +1,28 @@ + + + + + net48 + latest + false + true + CodingWithCalvin.Otel4Vsix.Tests + CodingWithCalvin.Otel4Vsix.Tests + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/ExceptionTrackerTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/ExceptionTrackerTests.cs new file mode 100644 index 0000000..6bd30f3 --- /dev/null +++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/ExceptionTrackerTests.cs @@ -0,0 +1,501 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Otel4Vsix.Exceptions; +using Xunit; + +namespace CodingWithCalvin.Otel4Vsix.Tests +{ + public class ExceptionTrackerTests : IDisposable + { + private ExceptionTracker _tracker; + private Mock _mockLogger; + private ActivityListener _listener; + + public ExceptionTrackerTests() + { + _mockLogger = new Mock(); + _mockLogger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + _mockLogger.Setup(l => l.BeginScope(It.IsAny())).Returns(Mock.Of()); + + // Set up an activity listener for activity-related tests + _listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(_listener); + } + + public void Dispose() + { + _tracker?.Dispose(); + _listener?.Dispose(); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + Action act = () => new ExceptionTracker(null); + + act.Should().Throw() + .And.ParamName.Should().Be("logger"); + } + + [Fact] + public void Constructor_WithValidLogger_Succeeds() + { + Action act = () => _tracker = new ExceptionTracker(_mockLogger.Object); + + act.Should().NotThrow(); + } + + [Fact] + public void Constructor_WithExceptionFilter_Succeeds() + { + Func filter = ex => ex is InvalidOperationException; + + Action act = () => _tracker = new ExceptionTracker(_mockLogger.Object, filter); + + act.Should().NotThrow(); + } + + [Fact] + public void TrackException_WithNullException_DoesNotThrow() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + + Action act = () => _tracker.TrackException(null); + + act.Should().NotThrow(); + } + + [Fact] + public void TrackException_WithNullException_DoesNotLog() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + + _tracker.TrackException(null); + + _mockLogger.Verify( + l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + + [Fact] + public void TrackException_WithValidException_LogsException() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + var exception = new InvalidOperationException("Test exception"); + + _tracker.TrackException(exception); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void TrackException_WithFilterReturningTrue_LogsException() + { + Func filter = ex => true; + _tracker = new ExceptionTracker(_mockLogger.Object, filter); + var exception = new InvalidOperationException("Test exception"); + + _tracker.TrackException(exception); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void TrackException_WithFilterReturningFalse_DoesNotLog() + { + Func filter = ex => false; + _tracker = new ExceptionTracker(_mockLogger.Object, filter); + var exception = new InvalidOperationException("Test exception"); + + _tracker.TrackException(exception); + + _mockLogger.Verify( + l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + + [Fact] + public void TrackException_WithSelectiveFilter_FiltersCorrectly() + { + Func filter = ex => ex is InvalidOperationException; + _tracker = new ExceptionTracker(_mockLogger.Object, filter); + var allowedException = new InvalidOperationException("Allowed"); + var filteredOutException = new ArgumentException("Filtered"); + + _tracker.TrackException(allowedException); + _tracker.TrackException(filteredOutException); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + allowedException, + It.IsAny>()), + Times.Once); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + filteredOutException, + It.IsAny>()), + Times.Never); + } + + [Fact] + public void TrackException_BeginsScope() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + var exception = new InvalidOperationException("Test exception"); + + _tracker.TrackException(exception); + + _mockLogger.Verify(l => l.BeginScope(It.IsAny()), Times.Once); + } + + [Fact] + public void TrackException_WithAdditionalAttributes_IncludesAttributes() + { + Dictionary capturedScope = null; + _mockLogger.Setup(l => l.BeginScope(It.IsAny>())) + .Callback>(scope => capturedScope = scope) + .Returns(Mock.Of()); + + _tracker = new ExceptionTracker(_mockLogger.Object); + var exception = new InvalidOperationException("Test exception"); + var additionalAttributes = new Dictionary + { + { "custom.key", "custom.value" }, + { "custom.number", 42 } + }; + + _tracker.TrackException(exception, additionalAttributes); + + capturedScope.Should().NotBeNull(); + capturedScope.Should().ContainKey("custom.key"); + capturedScope["custom.key"].Should().Be("custom.value"); + capturedScope.Should().ContainKey("custom.number"); + capturedScope["custom.number"].Should().Be(42); + } + + [Fact] + public void TrackException_IncludesExceptionType() + { + Dictionary capturedScope = null; + _mockLogger.Setup(l => l.BeginScope(It.IsAny>())) + .Callback>(scope => capturedScope = scope) + .Returns(Mock.Of()); + + _tracker = new ExceptionTracker(_mockLogger.Object); + var exception = new InvalidOperationException("Test"); + + _tracker.TrackException(exception); + + capturedScope.Should().ContainKey("exception.type"); + capturedScope["exception.type"].Should().Be(typeof(InvalidOperationException).FullName); + } + + [Fact] + public void TrackException_IncludesExceptionMessage() + { + Dictionary capturedScope = null; + _mockLogger.Setup(l => l.BeginScope(It.IsAny>())) + .Callback>(scope => capturedScope = scope) + .Returns(Mock.Of()); + + _tracker = new ExceptionTracker(_mockLogger.Object); + var exception = new InvalidOperationException("Test message"); + + _tracker.TrackException(exception); + + capturedScope.Should().ContainKey("exception.message"); + capturedScope["exception.message"].Should().Be("Test message"); + } + + [Fact] + public void TrackException_IncludesStackTrace() + { + Dictionary capturedScope = null; + _mockLogger.Setup(l => l.BeginScope(It.IsAny>())) + .Callback>(scope => capturedScope = scope) + .Returns(Mock.Of()); + + _tracker = new ExceptionTracker(_mockLogger.Object); + Exception exception; + try + { + throw new InvalidOperationException("Test"); + } + catch (Exception ex) + { + exception = ex; + } + + _tracker.TrackException(exception); + + capturedScope.Should().ContainKey("exception.stacktrace"); + capturedScope["exception.stacktrace"].Should().NotBeNull(); + ((string)capturedScope["exception.stacktrace"]).Should().Contain("TrackException_IncludesStackTrace"); + } + + [Fact] + public void TrackException_WithInnerException_IncludesInnerExceptionInfo() + { + Dictionary capturedScope = null; + _mockLogger.Setup(l => l.BeginScope(It.IsAny>())) + .Callback>(scope => capturedScope = scope) + .Returns(Mock.Of()); + + _tracker = new ExceptionTracker(_mockLogger.Object); + var innerException = new ArgumentException("Inner message"); + var exception = new InvalidOperationException("Outer message", innerException); + + _tracker.TrackException(exception); + + capturedScope.Should().ContainKey("exception.inner.type"); + capturedScope["exception.inner.type"].Should().Be(typeof(ArgumentException).FullName); + capturedScope.Should().ContainKey("exception.inner.message"); + capturedScope["exception.inner.message"].Should().Be("Inner message"); + } + + [Fact] + public void TrackException_WithExceptionData_IncludesDataEntries() + { + Dictionary capturedScope = null; + _mockLogger.Setup(l => l.BeginScope(It.IsAny>())) + .Callback>(scope => capturedScope = scope) + .Returns(Mock.Of()); + + _tracker = new ExceptionTracker(_mockLogger.Object); + var exception = new InvalidOperationException("Test"); + exception.Data["userId"] = "user123"; + exception.Data["operation"] = "save"; + + _tracker.TrackException(exception); + + capturedScope.Should().ContainKey("exception.data.userId"); + capturedScope["exception.data.userId"].Should().Be("user123"); + capturedScope.Should().ContainKey("exception.data.operation"); + capturedScope["exception.data.operation"].Should().Be("save"); + } + + [Fact] + public void TrackException_WithVsContextEnabled_IncludesVsContext() + { + Dictionary capturedScope = null; + _mockLogger.Setup(l => l.BeginScope(It.IsAny>())) + .Callback>(scope => capturedScope = scope) + .Returns(Mock.Of()); + + _tracker = new ExceptionTracker(_mockLogger.Object, includeVsContext: true); + var exception = new InvalidOperationException("Test"); + + _tracker.TrackException(exception); + + capturedScope.Should().ContainKey("vs.context.available"); + } + + [Fact] + public void TrackException_AfterDispose_ThrowsObjectDisposedException() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + _tracker.Dispose(); + + Action act = () => _tracker.TrackException(new InvalidOperationException("Test")); + + act.Should().Throw() + .And.ObjectName.Should().Be(nameof(ExceptionTracker)); + } + + [Fact] + public void RecordExceptionOnActivity_WithNullActivity_DoesNotThrow() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + + Action act = () => _tracker.RecordExceptionOnActivity(null, new InvalidOperationException("Test")); + + act.Should().NotThrow(); + } + + [Fact] + public void RecordExceptionOnActivity_WithNullException_DoesNotThrow() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + using var activitySource = new ActivitySource("Test"); + using var activity = activitySource.StartActivity("TestActivity"); + + Action act = () => _tracker.RecordExceptionOnActivity(activity, null); + + act.Should().NotThrow(); + } + + [Fact] + public void RecordExceptionOnActivity_SetsActivityStatusToError() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + using var activitySource = new ActivitySource("Test"); + using var activity = activitySource.StartActivity("TestActivity"); + var exception = new InvalidOperationException("Test error"); + + _tracker.RecordExceptionOnActivity(activity, exception); + + activity.Status.Should().Be(ActivityStatusCode.Error); + activity.StatusDescription.Should().Be("Test error"); + } + + [Fact] + public void RecordExceptionOnActivity_AddsExceptionEvent() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + using var activitySource = new ActivitySource("Test"); + using var activity = activitySource.StartActivity("TestActivity"); + var exception = new InvalidOperationException("Test error"); + + _tracker.RecordExceptionOnActivity(activity, exception); + + activity.Events.Should().ContainSingle(e => e.Name == "exception"); + } + + [Fact] + public void RecordExceptionOnActivity_WithFilter_RespectsFilter() + { + Func filter = ex => ex is InvalidOperationException; + _tracker = new ExceptionTracker(_mockLogger.Object, filter); + using var activitySource = new ActivitySource("Test"); + using var activity = activitySource.StartActivity("TestActivity"); + var filteredOutException = new ArgumentException("Filtered"); + + _tracker.RecordExceptionOnActivity(activity, filteredOutException); + + activity.Status.Should().Be(ActivityStatusCode.Unset); + activity.Events.Should().BeEmpty(); + } + + [Fact] + public void RecordExceptionOnActivity_AfterDispose_ThrowsObjectDisposedException() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + _tracker.Dispose(); + using var activitySource = new ActivitySource("Test"); + using var activity = activitySource.StartActivity("TestActivity"); + + Action act = () => _tracker.RecordExceptionOnActivity(activity, new InvalidOperationException()); + + act.Should().Throw(); + } + + [Fact] + public void RegisterGlobalExceptionHandler_IsIdempotent() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + + Action act = () => + { + _tracker.RegisterGlobalExceptionHandler(); + _tracker.RegisterGlobalExceptionHandler(); + _tracker.RegisterGlobalExceptionHandler(); + }; + + act.Should().NotThrow(); + } + + [Fact] + public void RegisterGlobalExceptionHandler_AfterDispose_ThrowsObjectDisposedException() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + _tracker.Dispose(); + + Action act = () => _tracker.RegisterGlobalExceptionHandler(); + + act.Should().Throw(); + } + + [Fact] + public void UnregisterGlobalExceptionHandler_IsIdempotent() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + _tracker.RegisterGlobalExceptionHandler(); + + Action act = () => + { + _tracker.UnregisterGlobalExceptionHandler(); + _tracker.UnregisterGlobalExceptionHandler(); + _tracker.UnregisterGlobalExceptionHandler(); + }; + + act.Should().NotThrow(); + } + + [Fact] + public void UnregisterGlobalExceptionHandler_WithoutRegistration_DoesNotThrow() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + + Action act = () => _tracker.UnregisterGlobalExceptionHandler(); + + act.Should().NotThrow(); + } + + [Fact] + public void Dispose_UnregistersGlobalHandler() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + _tracker.RegisterGlobalExceptionHandler(); + + Action act = () => _tracker.Dispose(); + + act.Should().NotThrow(); + // The handler is unregistered, subsequent operations will throw ObjectDisposedException + } + + [Fact] + public void Dispose_MultipleTimes_DoesNotThrow() + { + _tracker = new ExceptionTracker(_mockLogger.Object); + _tracker.RegisterGlobalExceptionHandler(); + + Action act = () => + { + _tracker.Dispose(); + _tracker.Dispose(); + _tracker.Dispose(); + }; + + act.Should().NotThrow(); + } + } +} diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/LoggerProviderTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/LoggerProviderTests.cs new file mode 100644 index 0000000..3317c27 --- /dev/null +++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/LoggerProviderTests.cs @@ -0,0 +1,379 @@ +using System; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Otel4Vsix.Logging; +using Xunit; + +namespace CodingWithCalvin.Otel4Vsix.Tests +{ + public class LoggerProviderTests : IDisposable + { + private LoggerProvider _provider; + private Mock _mockLoggerFactory; + private Mock _mockLogger; + + public LoggerProviderTests() + { + _mockLogger = new Mock(); + _mockLoggerFactory = new Mock(); + _mockLoggerFactory + .Setup(f => f.CreateLogger(It.IsAny())) + .Returns(_mockLogger.Object); + } + + public void Dispose() + { + _provider?.Dispose(); + } + + [Fact] + public void Constructor_WithNullLoggerFactory_ThrowsArgumentNullException() + { + Action act = () => new LoggerProvider(null, "TestCategory"); + + act.Should().Throw() + .And.ParamName.Should().Be("loggerFactory"); + } + + [Fact] + public void Constructor_WithValidLoggerFactory_Succeeds() + { + Action act = () => _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + act.Should().NotThrow(); + } + + [Fact] + public void Constructor_WithNullCategoryName_UsesDefaultCategory() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, null); + + _mockLoggerFactory.Verify(f => f.CreateLogger("Otel4Vsix"), Times.Once); + } + + [Fact] + public void Constructor_WithCategoryName_UsesProvidedCategory() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "CustomCategory"); + + _mockLoggerFactory.Verify(f => f.CreateLogger("CustomCategory"), Times.Once); + } + + [Fact] + public void Logger_BeforeDispose_ReturnsLogger() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + var logger = _provider.Logger; + + logger.Should().NotBeNull(); + logger.Should().BeSameAs(_mockLogger.Object); + } + + [Fact] + public void Logger_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => { var _ = _provider.Logger; }; + + act.Should().Throw() + .And.ObjectName.Should().Be(nameof(LoggerProvider)); + } + + [Fact] + public void LoggerFactory_BeforeDispose_ReturnsFactory() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + var factory = _provider.LoggerFactory; + + factory.Should().NotBeNull(); + factory.Should().BeSameAs(_mockLoggerFactory.Object); + } + + [Fact] + public void LoggerFactory_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => { var _ = _provider.LoggerFactory; }; + + act.Should().Throw(); + } + + [Fact] + public void CreateLogger_DelegatesToFactory() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + _provider.CreateLogger("NewCategory"); + + _mockLoggerFactory.Verify(f => f.CreateLogger("NewCategory"), Times.Once); + } + + [Fact] + public void CreateLogger_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => _provider.CreateLogger("NewCategory"); + + act.Should().Throw(); + } + + [Fact] + public void CreateLoggerGeneric_DelegatesToFactory() + { + var mockGenericLogger = new Mock>(); + _mockLoggerFactory + .Setup(f => f.CreateLogger(typeof(LoggerProviderTests).FullName)) + .Returns(mockGenericLogger.Object); + + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + var logger = _provider.CreateLogger(); + + logger.Should().NotBeNull(); + } + + [Fact] + public void CreateLoggerGeneric_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => _provider.CreateLogger(); + + act.Should().Throw(); + } + + [Fact] + public void LogInformation_DelegatesToUnderlyingLogger() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + _provider.LogInformation("Test message {Param}", "value"); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Information, + It.IsAny(), + It.IsAny(), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void LogInformation_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => _provider.LogInformation("Test"); + + act.Should().Throw(); + } + + [Fact] + public void LogWarning_DelegatesToUnderlyingLogger() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + _provider.LogWarning("Warning message"); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void LogWarning_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => _provider.LogWarning("Test"); + + act.Should().Throw(); + } + + [Fact] + public void LogError_DelegatesToUnderlyingLogger() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + _provider.LogError("Error message"); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void LogError_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => _provider.LogError("Test"); + + act.Should().Throw(); + } + + [Fact] + public void LogErrorWithException_DelegatesToUnderlyingLogger() + { + var exception = new InvalidOperationException("Test exception"); + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + _provider.LogError(exception, "Error with exception"); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void LogErrorWithException_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => _provider.LogError(new Exception(), "Test"); + + act.Should().Throw(); + } + + [Fact] + public void LogDebug_DelegatesToUnderlyingLogger() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + _provider.LogDebug("Debug message"); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Debug, + It.IsAny(), + It.IsAny(), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void LogDebug_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => _provider.LogDebug("Test"); + + act.Should().Throw(); + } + + [Fact] + public void LogCritical_DelegatesToUnderlyingLogger() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + _provider.LogCritical("Critical message"); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Critical, + It.IsAny(), + It.IsAny(), + null, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void LogCritical_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => _provider.LogCritical("Test"); + + act.Should().Throw(); + } + + [Fact] + public void LogCriticalWithException_DelegatesToUnderlyingLogger() + { + var exception = new InvalidOperationException("Critical exception"); + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + _provider.LogCritical(exception, "Critical with exception"); + + _mockLogger.Verify( + l => l.Log( + LogLevel.Critical, + It.IsAny(), + It.IsAny(), + exception, + It.IsAny>()), + Times.Once); + } + + [Fact] + public void LogCriticalWithException_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + _provider.Dispose(); + + Action act = () => _provider.LogCritical(new Exception(), "Test"); + + act.Should().Throw(); + } + + [Fact] + public void Dispose_DisposesUnderlyingFactory() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + _provider.Dispose(); + + _mockLoggerFactory.Verify(f => f.Dispose(), Times.Once); + } + + [Fact] + public void Dispose_MultipleTimes_DoesNotThrow() + { + _provider = new LoggerProvider(_mockLoggerFactory.Object, "TestCategory"); + + Action act = () => + { + _provider.Dispose(); + _provider.Dispose(); + _provider.Dispose(); + }; + + act.Should().NotThrow(); + _mockLoggerFactory.Verify(f => f.Dispose(), Times.Once); + } + } +} diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/MetricsProviderTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/MetricsProviderTests.cs new file mode 100644 index 0000000..6347dea --- /dev/null +++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/MetricsProviderTests.cs @@ -0,0 +1,258 @@ +using System; +using System.Diagnostics.Metrics; +using FluentAssertions; +using Otel4Vsix.Metrics; +using Xunit; + +namespace CodingWithCalvin.Otel4Vsix.Tests +{ + public class MetricsProviderTests : IDisposable + { + private MetricsProvider _provider; + + public void Dispose() + { + _provider?.Dispose(); + } + + [Fact] + public void Constructor_WithNullServiceName_ThrowsArgumentNullException() + { + Action act = () => new MetricsProvider(null, "1.0.0"); + + act.Should().Throw() + .And.ParamName.Should().Be("serviceName"); + } + + [Fact] + public void Constructor_WithEmptyServiceName_ThrowsArgumentNullException() + { + Action act = () => new MetricsProvider(string.Empty, "1.0.0"); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithWhitespaceServiceName_ThrowsArgumentNullException() + { + Action act = () => new MetricsProvider(" ", "1.0.0"); + + act.Should().Throw(); + } + + [Fact] + public void Constructor_WithValidName_CreatesMeter() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + _provider.Meter.Should().NotBeNull(); + _provider.Meter.Name.Should().Be("TestService"); + _provider.Meter.Version.Should().Be("1.0.0"); + } + + [Fact] + public void Constructor_WithNullVersion_UsesDefaultVersion() + { + _provider = new MetricsProvider("TestService", null); + + _provider.Meter.Should().NotBeNull(); + _provider.Meter.Version.Should().Be("1.0.0"); + } + + [Fact] + public void Meter_BeforeDispose_ReturnsMeter() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var meter = _provider.Meter; + + meter.Should().NotBeNull(); + } + + [Fact] + public void Meter_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + _provider.Dispose(); + + Action act = () => { var _ = _provider.Meter; }; + + act.Should().Throw() + .And.ObjectName.Should().Be(nameof(MetricsProvider)); + } + + [Fact] + public void GetOrCreateCounter_ReturnsCounter() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var counter = _provider.GetOrCreateCounter("test.counter", "items", "A test counter"); + + counter.Should().NotBeNull(); + } + + [Fact] + public void GetOrCreateCounter_ReturnsCachedInstance() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var counter1 = _provider.GetOrCreateCounter("test.counter"); + var counter2 = _provider.GetOrCreateCounter("test.counter"); + + counter1.Should().BeSameAs(counter2); + } + + [Fact] + public void GetOrCreateCounter_DifferentNames_ReturnsDifferentInstances() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var counter1 = _provider.GetOrCreateCounter("counter.one"); + var counter2 = _provider.GetOrCreateCounter("counter.two"); + + counter1.Should().NotBeSameAs(counter2); + } + + [Fact] + public void GetOrCreateCounter_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + _provider.Dispose(); + + Action act = () => _provider.GetOrCreateCounter("test.counter"); + + act.Should().Throw(); + } + + [Fact] + public void GetOrCreateHistogram_ReturnsHistogram() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var histogram = _provider.GetOrCreateHistogram("test.histogram", "ms", "A test histogram"); + + histogram.Should().NotBeNull(); + } + + [Fact] + public void GetOrCreateHistogram_ReturnsCachedInstance() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var histogram1 = _provider.GetOrCreateHistogram("test.histogram"); + var histogram2 = _provider.GetOrCreateHistogram("test.histogram"); + + histogram1.Should().BeSameAs(histogram2); + } + + [Fact] + public void GetOrCreateHistogram_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + _provider.Dispose(); + + Action act = () => _provider.GetOrCreateHistogram("test.histogram"); + + act.Should().Throw(); + } + + [Fact] + public void GetOrCreateUpDownCounter_ReturnsUpDownCounter() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var counter = _provider.GetOrCreateUpDownCounter("test.updown", "items", "A test up-down counter"); + + counter.Should().NotBeNull(); + } + + [Fact] + public void GetOrCreateUpDownCounter_ReturnsCachedInstance() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var counter1 = _provider.GetOrCreateUpDownCounter("test.updown"); + var counter2 = _provider.GetOrCreateUpDownCounter("test.updown"); + + counter1.Should().BeSameAs(counter2); + } + + [Fact] + public void GetOrCreateUpDownCounter_AfterDispose_ThrowsObjectDisposedException() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + _provider.Dispose(); + + Action act = () => _provider.GetOrCreateUpDownCounter("test.updown"); + + act.Should().Throw(); + } + + [Fact] + public void CreateCommandExecutionCounter_ReturnsCounterWithCorrectName() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var counter = _provider.CreateCommandExecutionCounter(); + + counter.Should().NotBeNull(); + // The counter is created, we can verify it's cached + var counter2 = _provider.GetOrCreateCounter("vs.extension.commands.executed"); + counter.Should().BeSameAs(counter2); + } + + [Fact] + public void CreateOperationDurationHistogram_ReturnsHistogramWithCorrectName() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var histogram = _provider.CreateOperationDurationHistogram(); + + histogram.Should().NotBeNull(); + // The histogram is created, we can verify it's cached + var histogram2 = _provider.GetOrCreateHistogram("vs.extension.operation.duration"); + histogram.Should().BeSameAs(histogram2); + } + + [Fact] + public void CreateErrorCounter_ReturnsCounterWithCorrectName() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + var counter = _provider.CreateErrorCounter(); + + counter.Should().NotBeNull(); + // The counter is created, we can verify it's cached + var counter2 = _provider.GetOrCreateCounter("vs.extension.errors"); + counter.Should().BeSameAs(counter2); + } + + [Fact] + public void Dispose_MultipleTimes_DoesNotThrow() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + + Action act = () => + { + _provider.Dispose(); + _provider.Dispose(); + _provider.Dispose(); + }; + + act.Should().NotThrow(); + } + + [Fact] + public void Dispose_ClearsInstrumentCache() + { + _provider = new MetricsProvider("TestService", "1.0.0"); + _provider.GetOrCreateCounter("test.counter"); + + _provider.Dispose(); + + // After dispose, accessing anything should throw + Action act = () => _provider.GetOrCreateCounter("test.counter"); + act.Should().Throw(); + } + } +} diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/TelemetryConfigurationTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/TelemetryConfigurationTests.cs new file mode 100644 index 0000000..ce24907 --- /dev/null +++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/TelemetryConfigurationTests.cs @@ -0,0 +1,241 @@ +using System; +using FluentAssertions; +using Otel4Vsix; +using Xunit; + +namespace CodingWithCalvin.Otel4Vsix.Tests +{ + public class TelemetryConfigurationTests + { + [Fact] + public void Defaults_AreSetCorrectly() + { + var config = new TelemetryConfiguration(); + + config.ServiceName.Should().Be("VsixExtension"); + config.ServiceVersion.Should().Be("1.0.0"); + config.OtlpEndpoint.Should().BeNull(); + config.UseOtlpHttp.Should().BeFalse(); + config.EnableConsoleExporter.Should().BeFalse(); + config.EnableTracing.Should().BeTrue(); + config.EnableMetrics.Should().BeTrue(); + config.EnableLogging.Should().BeTrue(); + config.EnableGlobalExceptionHandler.Should().BeTrue(); + config.TraceSamplingRatio.Should().Be(1.0); + config.ExceptionFilter.Should().BeNull(); + config.IncludeVisualStudioContext.Should().BeTrue(); + config.ExportTimeoutMilliseconds.Should().Be(30000); + config.BatchExportScheduledDelayMilliseconds.Should().Be(5000); + } + + [Fact] + public void OtlpHeaders_InitializedAsEmptyDictionary() + { + var config = new TelemetryConfiguration(); + + config.OtlpHeaders.Should().NotBeNull(); + config.OtlpHeaders.Should().BeEmpty(); + } + + [Fact] + public void ResourceAttributes_InitializedAsEmptyDictionary() + { + var config = new TelemetryConfiguration(); + + config.ResourceAttributes.Should().NotBeNull(); + config.ResourceAttributes.Should().BeEmpty(); + } + + [Fact] + public void Validate_WithValidConfig_Succeeds() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + ServiceVersion = "2.0.0", + TraceSamplingRatio = 0.5, + ExportTimeoutMilliseconds = 10000, + BatchExportScheduledDelayMilliseconds = 1000 + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeNull(); + } + + [Fact] + public void Validate_WithNullServiceName_ThrowsArgumentException() + { + var config = new TelemetryConfiguration + { + ServiceName = null + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeOfType(); + exception.Message.Should().Contain("ServiceName"); + } + + [Fact] + public void Validate_WithEmptyServiceName_ThrowsArgumentException() + { + var config = new TelemetryConfiguration + { + ServiceName = string.Empty + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeOfType(); + } + + [Fact] + public void Validate_WithWhitespaceServiceName_ThrowsArgumentException() + { + var config = new TelemetryConfiguration + { + ServiceName = " " + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeOfType(); + } + + [Fact] + public void Validate_WithNegativeSamplingRatio_ThrowsArgumentOutOfRangeException() + { + var config = new TelemetryConfiguration + { + TraceSamplingRatio = -0.1 + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeOfType(); + ((ArgumentOutOfRangeException)exception).ParamName.Should().Be("TraceSamplingRatio"); + } + + [Fact] + public void Validate_WithSamplingRatioAboveOne_ThrowsArgumentOutOfRangeException() + { + var config = new TelemetryConfiguration + { + TraceSamplingRatio = 1.1 + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeOfType(); + ((ArgumentOutOfRangeException)exception).ParamName.Should().Be("TraceSamplingRatio"); + } + + [Theory] + [InlineData(0.0)] + [InlineData(0.5)] + [InlineData(1.0)] + public void Validate_WithValidSamplingRatio_Succeeds(double samplingRatio) + { + var config = new TelemetryConfiguration + { + TraceSamplingRatio = samplingRatio + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeNull(); + } + + [Fact] + public void Validate_WithZeroExportTimeout_ThrowsArgumentOutOfRangeException() + { + var config = new TelemetryConfiguration + { + ExportTimeoutMilliseconds = 0 + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeOfType(); + ((ArgumentOutOfRangeException)exception).ParamName.Should().Be("ExportTimeoutMilliseconds"); + } + + [Fact] + public void Validate_WithNegativeExportTimeout_ThrowsArgumentOutOfRangeException() + { + var config = new TelemetryConfiguration + { + ExportTimeoutMilliseconds = -1 + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeOfType(); + } + + [Fact] + public void Validate_WithZeroBatchDelay_ThrowsArgumentOutOfRangeException() + { + var config = new TelemetryConfiguration + { + BatchExportScheduledDelayMilliseconds = 0 + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeOfType(); + ((ArgumentOutOfRangeException)exception).ParamName.Should().Be("BatchExportScheduledDelayMilliseconds"); + } + + [Fact] + public void Validate_WithNegativeBatchDelay_ThrowsArgumentOutOfRangeException() + { + var config = new TelemetryConfiguration + { + BatchExportScheduledDelayMilliseconds = -100 + }; + + var exception = Record.Exception(() => config.Validate()); + + exception.Should().BeOfType(); + } + + [Fact] + public void OtlpHeaders_CanAddHeaders() + { + var config = new TelemetryConfiguration(); + + config.OtlpHeaders["x-api-key"] = "test-key"; + config.OtlpHeaders["Authorization"] = "Bearer token"; + + config.OtlpHeaders.Should().HaveCount(2); + config.OtlpHeaders["x-api-key"].Should().Be("test-key"); + } + + [Fact] + public void ResourceAttributes_CanAddAttributes() + { + var config = new TelemetryConfiguration(); + + config.ResourceAttributes["environment"] = "test"; + config.ResourceAttributes["instance.id"] = 123; + + config.ResourceAttributes.Should().HaveCount(2); + config.ResourceAttributes["environment"].Should().Be("test"); + } + + [Fact] + public void ExceptionFilter_CanBeSet() + { + var config = new TelemetryConfiguration(); + Func filter = ex => ex is InvalidOperationException; + + config.ExceptionFilter = filter; + + config.ExceptionFilter.Should().NotBeNull(); + config.ExceptionFilter(new InvalidOperationException()).Should().BeTrue(); + config.ExceptionFilter(new ArgumentException()).Should().BeFalse(); + } + } +} diff --git a/tests/CodingWithCalvin.Otel4Vsix.Tests/VsixTelemetryTests.cs b/tests/CodingWithCalvin.Otel4Vsix.Tests/VsixTelemetryTests.cs new file mode 100644 index 0000000..38198fb --- /dev/null +++ b/tests/CodingWithCalvin.Otel4Vsix.Tests/VsixTelemetryTests.cs @@ -0,0 +1,516 @@ +using System; +using System.Diagnostics; +using FluentAssertions; +using Otel4Vsix; +using Xunit; + +namespace CodingWithCalvin.Otel4Vsix.Tests +{ + /// + /// Tests for VsixTelemetry static class. + /// These tests must run sequentially due to shared static state. + /// + [Collection("VsixTelemetry")] + public class VsixTelemetryTests : IDisposable + { + public VsixTelemetryTests() + { + // Ensure clean state before each test + VsixTelemetry.Shutdown(); + } + + public void Dispose() + { + // Clean up after each test + VsixTelemetry.Shutdown(); + } + + [Fact] + public void IsInitialized_BeforeInitialize_ReturnsFalse() + { + VsixTelemetry.IsInitialized.Should().BeFalse(); + } + + [Fact] + public void Initialize_WithNullConfig_ThrowsArgumentNullException() + { + Action act = () => VsixTelemetry.Initialize(null); + + act.Should().Throw() + .And.ParamName.Should().Be("configuration"); + } + + [Fact] + public void Initialize_WithInvalidConfig_ThrowsArgumentException() + { + var config = new TelemetryConfiguration + { + ServiceName = null + }; + + Action act = () => VsixTelemetry.Initialize(config); + + act.Should().Throw(); + } + + [Fact] + public void Initialize_WithValidConfig_SetsIsInitializedTrue() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableConsoleExporter = false + }; + + VsixTelemetry.Initialize(config); + + VsixTelemetry.IsInitialized.Should().BeTrue(); + } + + [Fact] + public void Initialize_WhenAlreadyInitialized_ThrowsInvalidOperationException() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService" + }; + VsixTelemetry.Initialize(config); + + Action act = () => VsixTelemetry.Initialize(config); + + act.Should().Throw() + .WithMessage("*already initialized*"); + } + + [Fact] + public void Tracer_BeforeInitialize_ThrowsInvalidOperationException() + { + Action act = () => { var _ = VsixTelemetry.Tracer; }; + + act.Should().Throw() + .WithMessage("*not initialized*"); + } + + [Fact] + public void Tracer_AfterInitialize_ReturnsActivitySource() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableTracing = true + }; + VsixTelemetry.Initialize(config); + + var tracer = VsixTelemetry.Tracer; + + tracer.Should().NotBeNull(); + tracer.Name.Should().Be("TestService"); + } + + [Fact] + public void Meter_BeforeInitialize_ThrowsInvalidOperationException() + { + Action act = () => { var _ = VsixTelemetry.Meter; }; + + act.Should().Throw() + .WithMessage("*not initialized*"); + } + + [Fact] + public void Meter_AfterInitialize_ReturnsMeter() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableMetrics = true + }; + VsixTelemetry.Initialize(config); + + var meter = VsixTelemetry.Meter; + + meter.Should().NotBeNull(); + meter.Name.Should().Be("TestService"); + } + + [Fact] + public void Logger_BeforeInitialize_ThrowsInvalidOperationException() + { + Action act = () => { var _ = VsixTelemetry.Logger; }; + + act.Should().Throw() + .WithMessage("*not initialized*"); + } + + [Fact] + public void Logger_AfterInitialize_ReturnsLogger() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableLogging = true + }; + VsixTelemetry.Initialize(config); + + var logger = VsixTelemetry.Logger; + + logger.Should().NotBeNull(); + } + + [Fact] + public void LoggerFactory_BeforeInitialize_ThrowsInvalidOperationException() + { + Action act = () => { var _ = VsixTelemetry.LoggerFactory; }; + + act.Should().Throw() + .WithMessage("*not initialized*"); + } + + [Fact] + public void LoggerFactory_AfterInitialize_ReturnsLoggerFactory() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableLogging = true + }; + VsixTelemetry.Initialize(config); + + var loggerFactory = VsixTelemetry.LoggerFactory; + + loggerFactory.Should().NotBeNull(); + } + + [Fact] + public void Shutdown_ResetsIsInitializedToFalse() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService" + }; + VsixTelemetry.Initialize(config); + VsixTelemetry.IsInitialized.Should().BeTrue(); + + VsixTelemetry.Shutdown(); + + VsixTelemetry.IsInitialized.Should().BeFalse(); + } + + [Fact] + public void Shutdown_WhenNotInitialized_DoesNotThrow() + { + Action act = () => VsixTelemetry.Shutdown(); + + act.Should().NotThrow(); + } + + [Fact] + public void Shutdown_AllowsReinitialization() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService" + }; + VsixTelemetry.Initialize(config); + VsixTelemetry.Shutdown(); + + Action act = () => VsixTelemetry.Initialize(config); + + act.Should().NotThrow(); + VsixTelemetry.IsInitialized.Should().BeTrue(); + } + + [Fact] + public void TrackException_BeforeInitialize_DoesNotThrow() + { + Action act = () => VsixTelemetry.TrackException(new InvalidOperationException("Test")); + + act.Should().NotThrow(); + } + + [Fact] + public void TrackException_AfterInitialize_DoesNotThrow() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService" + }; + VsixTelemetry.Initialize(config); + + Action act = () => VsixTelemetry.TrackException(new InvalidOperationException("Test")); + + act.Should().NotThrow(); + } + + [Fact] + public void TrackException_WithNullException_DoesNotThrow() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService" + }; + VsixTelemetry.Initialize(config); + + Action act = () => VsixTelemetry.TrackException(null); + + act.Should().NotThrow(); + } + + [Fact] + public void StartActivity_BeforeInitialize_ReturnsNull() + { + var activity = VsixTelemetry.StartActivity("TestActivity"); + + activity.Should().BeNull(); + } + + [Fact] + public void StartCommandActivity_BeforeInitialize_ReturnsNull() + { + var activity = VsixTelemetry.StartCommandActivity("TestCommand"); + + activity.Should().BeNull(); + } + + [Fact] + public void CreateLoggerGeneric_BeforeInitialize_ReturnsNull() + { + var logger = VsixTelemetry.CreateLogger(); + + logger.Should().BeNull(); + } + + [Fact] + public void CreateLogger_BeforeInitialize_ReturnsNull() + { + var logger = VsixTelemetry.CreateLogger("TestCategory"); + + logger.Should().BeNull(); + } + + [Fact] + public void CreateLoggerGeneric_AfterInitialize_ReturnsLogger() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableLogging = true + }; + VsixTelemetry.Initialize(config); + + var logger = VsixTelemetry.CreateLogger(); + + logger.Should().NotBeNull(); + } + + [Fact] + public void CreateLogger_AfterInitialize_ReturnsLogger() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableLogging = true + }; + VsixTelemetry.Initialize(config); + + var logger = VsixTelemetry.CreateLogger("TestCategory"); + + logger.Should().NotBeNull(); + } + + [Fact] + public void GetOrCreateCounter_BeforeInitialize_ReturnsNull() + { + var counter = VsixTelemetry.GetOrCreateCounter("test.counter"); + + counter.Should().BeNull(); + } + + [Fact] + public void GetOrCreateCounter_AfterInitialize_ReturnsCounter() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableMetrics = true + }; + VsixTelemetry.Initialize(config); + + var counter = VsixTelemetry.GetOrCreateCounter("test.counter"); + + counter.Should().NotBeNull(); + } + + [Fact] + public void GetOrCreateHistogram_BeforeInitialize_ReturnsNull() + { + var histogram = VsixTelemetry.GetOrCreateHistogram("test.histogram"); + + histogram.Should().BeNull(); + } + + [Fact] + public void GetOrCreateHistogram_AfterInitialize_ReturnsHistogram() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableMetrics = true + }; + VsixTelemetry.Initialize(config); + + var histogram = VsixTelemetry.GetOrCreateHistogram("test.histogram"); + + histogram.Should().NotBeNull(); + } + + [Fact] + public void Initialize_WithTracingDisabled_TracerThrowsInvalidOperationException() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableTracing = false, + EnableMetrics = true, + EnableLogging = true + }; + VsixTelemetry.Initialize(config); + + // When tracing is disabled, the activity source provider is null + // Accessing Tracer should throw because the provider is null + Action act = () => { var _ = VsixTelemetry.Tracer; }; + + // This will throw NullReferenceException internally, wrapped in InvalidOperationException + // or throw directly based on implementation + act.Should().Throw(); + } + + [Fact] + public void Initialize_WithMetricsDisabled_MeterThrowsException() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableTracing = true, + EnableMetrics = false, + EnableLogging = true + }; + VsixTelemetry.Initialize(config); + + Action act = () => { var _ = VsixTelemetry.Meter; }; + + act.Should().Throw(); + } + + [Fact] + public void Initialize_WithLoggingDisabled_LoggerThrowsException() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableTracing = true, + EnableMetrics = true, + EnableLogging = false + }; + VsixTelemetry.Initialize(config); + + Action act = () => { var _ = VsixTelemetry.Logger; }; + + act.Should().Throw(); + } + + [Fact] + public void Initialize_WithOtlpEndpoint_Succeeds() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + OtlpEndpoint = "http://localhost:4317" + }; + + Action act = () => VsixTelemetry.Initialize(config); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_WithOtlpHeaders_Succeeds() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + OtlpEndpoint = "http://localhost:4317" + }; + config.OtlpHeaders["x-api-key"] = "test-key"; + + Action act = () => VsixTelemetry.Initialize(config); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_WithResourceAttributes_Succeeds() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService" + }; + config.ResourceAttributes["environment"] = "test"; + config.ResourceAttributes["instance.id"] = "123"; + + Action act = () => VsixTelemetry.Initialize(config); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_WithExceptionFilter_Succeeds() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + ExceptionFilter = ex => ex is InvalidOperationException + }; + + Action act = () => VsixTelemetry.Initialize(config); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_WithGlobalExceptionHandlerDisabled_Succeeds() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + EnableGlobalExceptionHandler = false + }; + + Action act = () => VsixTelemetry.Initialize(config); + + act.Should().NotThrow(); + } + + [Fact] + public void Initialize_WithHttpProtocol_Succeeds() + { + var config = new TelemetryConfiguration + { + ServiceName = "TestService", + OtlpEndpoint = "http://localhost:4318", + UseOtlpHttp = true + }; + + Action act = () => VsixTelemetry.Initialize(config); + + act.Should().NotThrow(); + } + } + + /// + /// Collection definition to ensure VsixTelemetry tests run sequentially. + /// + [CollectionDefinition("VsixTelemetry", DisableParallelization = true)] + public class VsixTelemetryCollection + { + } +}