// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Purview.Models.Common; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Moq.Protected; namespace Microsoft.Agents.AI.Purview.UnitTests; /// /// Unit tests for the class. /// public sealed class PurviewWrapperTests : IDisposable { private readonly Mock _mockProcessor; private readonly IBackgroundJobRunner _backgroundJobRunner; private readonly PurviewSettings _settings; private readonly PurviewWrapper _wrapper; public PurviewWrapperTests() { this._mockProcessor = new Mock(); this._settings = new PurviewSettings("TestApp") { TenantId = "tenant-123", PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123"), BlockedPromptMessage = "Prompt blocked by policy", BlockedResponseMessage = "Response blocked by policy" }; this._backgroundJobRunner = Mock.Of(); this._wrapper = new PurviewWrapper(this._mockProcessor.Object, this._settings, NullLogger.Instance, this._backgroundJobRunner); } #region ProcessChatContentAsync Tests [Fact] public async Task ProcessChatContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync() { // Arrange var messages = new List { new(ChatRole.User, "Sensitive content that should be blocked") }; var mockChatClient = new Mock(); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((true, "user-123")); // Act var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Single(result.Messages); Assert.Equal(ChatRole.System, result.Messages[0].Role); Assert.Equal("Prompt blocked by policy", result.Messages[0].Text); mockChatClient.Verify(x => x.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task ProcessChatContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync() { // Arrange var messages = new List { new(ChatRole.User, "Test message") }; var mockChatClient = new Mock(); var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")); mockChatClient.Setup(x => x.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(innerResponse); // Prompt check uses UploadText, response check uses DownloadText this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((false, "user-123")); // Prompt allowed this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), Activity.DownloadText, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((true, "user-123")); // Response blocked // Act var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Single(result.Messages); Assert.Equal(ChatRole.System, result.Messages[0].Role); Assert.Equal("Response blocked by policy", result.Messages[0].Text); } [Fact] public async Task ProcessChatContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync() { // Arrange var messages = new List { new(ChatRole.User, "Test message") }; var mockChatClient = new Mock(); var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Safe response")); mockChatClient.Setup(x => x.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(innerResponse); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((false, "user-123")); // Act var result = await this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); // Assert Assert.Same(innerResponse, result); } [Fact] public async Task ProcessChatContentAsync_WithIgnoreExceptions_ContinuesOnPromptErrorAsync() { // Arrange var settingsWithIgnore = new PurviewSettings("TestApp") { TenantId = "tenant-123", IgnoreExceptions = true, PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") }; var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._backgroundJobRunner); var messages = new List { new(ChatRole.User, "Test message") }; var expectedResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response from inner client")); var mockChatClient = new Mock(); mockChatClient.Setup(x => x.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(expectedResponse); this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new PurviewRequestException("Prompt processing error")); // Response processing succeeds // Act var result = await wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Same(expectedResponse, result); } [Fact] public async Task ProcessChatContentAsync_WithoutIgnoreExceptions_ThrowsOnPromptErrorAsync() { // Arrange var messages = new List { new(ChatRole.User, "Test message") }; var mockChatClient = new Mock(); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new PurviewRequestException("Prompt processing error")); // Act & Assert await Assert.ThrowsAsync(() => this._wrapper.ProcessChatContentAsync(messages, null, mockChatClient.Object, CancellationToken.None)); } [Fact] public async Task ProcessChatContentAsync_UsesConversationIdFromOptions_Async() { // Arrange var messages = new List { new(ChatRole.User, "Test message") }; var options = new ChatOptions { ConversationId = "conversation-123" }; var mockChatClient = new Mock(); var innerResponse = new ChatResponse(new ChatMessage(ChatRole.Assistant, "Response")); mockChatClient.Setup(x => x.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(innerResponse); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), "conversation-123", It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((false, "user-123")); // Act await this._wrapper.ProcessChatContentAsync(messages, options, mockChatClient.Object, CancellationToken.None); // Assert - verify prompt uses UploadText and response uses DownloadText this._mockProcessor.Verify(x => x.ProcessMessagesAsync( It.IsAny>(), "conversation-123", Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); this._mockProcessor.Verify(x => x.ProcessMessagesAsync( It.IsAny>(), "conversation-123", Activity.DownloadText, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } #endregion #region ProcessAgentContentAsync Tests [Fact] public async Task ProcessAgentContentAsync_WithBlockedPrompt_ReturnsBlockedMessageAsync() { // Arrange var messages = new List { new(ChatRole.User, "Sensitive content") }; var mockAgent = new Mock(); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((true, "user-123")); // Act var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Single(result.Messages); Assert.Equal(ChatRole.System, result.Messages[0].Role); Assert.Equal("Prompt blocked by policy", result.Messages[0].Text); mockAgent.Protected().Verify("RunCoreAsync", Times.Never(), ItExpr.IsAny>(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()); } [Fact] public async Task ProcessAgentContentAsync_WithAllowedPromptAndBlockedResponse_ReturnsBlockedMessageAsync() { // Arrange var messages = new List { new(ChatRole.User, "Test message") }; var mockAgent = new Mock(); var innerResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response")); mockAgent.Protected() .Setup>("RunCoreAsync", ItExpr.IsAny>(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(innerResponse); // Prompt check uses UploadText, response check uses DownloadText this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((false, "user-123")); // Prompt allowed this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), Activity.DownloadText, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((true, "user-123")); // Response blocked // Act var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Single(result.Messages); Assert.Equal(ChatRole.System, result.Messages[0].Role); Assert.Equal("Response blocked by policy", result.Messages[0].Text); } [Fact] public async Task ProcessAgentContentAsync_WithAllowedPromptAndResponse_ReturnsInnerResponseAsync() { // Arrange var messages = new List { new(ChatRole.User, "Test message") }; var mockAgent = new Mock(); var innerResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Safe response")); mockAgent.Protected() .Setup>("RunCoreAsync", ItExpr.IsAny>(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(innerResponse); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((false, "user-123")); // Act var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); // Assert Assert.Same(innerResponse, result); } [Fact] public async Task ProcessAgentContentAsync_WithIgnoreExceptions_ContinuesOnErrorAsync() { // Arrange var settingsWithIgnore = new PurviewSettings("TestApp") { TenantId = "tenant-123", IgnoreExceptions = true, PurviewAppLocation = new PurviewAppLocation(PurviewLocationType.Application, "app-123") }; var wrapper = new PurviewWrapper(this._mockProcessor.Object, settingsWithIgnore, NullLogger.Instance, this._backgroundJobRunner); var messages = new List { new(ChatRole.User, "Test message") }; var expectedResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent")); var mockAgent = new Mock(); mockAgent.Protected() .Setup>("RunCoreAsync", ItExpr.IsAny>(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(expectedResponse); this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new PurviewRequestException("Prompt processing error")) .ReturnsAsync((false, "user-123")); // Response processing succeeds // Act var result = await wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); // Assert Assert.NotNull(result); Assert.Same(expectedResponse, result); } [Fact] public async Task ProcessAgentContentAsync_WithoutIgnoreExceptions_ThrowsOnErrorAsync() { // Arrange var messages = new List { new(ChatRole.User, "Test message") }; var mockAgent = new Mock(); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ThrowsAsync(new PurviewRequestException("Processing error")); // Act & Assert await Assert.ThrowsAsync(() => this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None)); } [Fact] public async Task ProcessAgentContentAsync_ExtractsThreadIdFromMessageAdditionalProperties_Async() { // Arrange var messages = new List { new(ChatRole.User, "Test message") { AdditionalProperties = new AdditionalPropertiesDictionary { { "conversationId", "conversation-from-props" } } } }; var expectedResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Response")); var mockAgent = new Mock(); mockAgent.Protected() .Setup>("RunCoreAsync", ItExpr.IsAny>(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(expectedResponse); this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), "conversation-from-props", It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((false, "user-123")); // Act var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); // Assert Assert.NotNull(result); this._mockProcessor.Verify(x => x.ProcessMessagesAsync( It.IsAny>(), "conversation-from-props", Activity.UploadText, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); this._mockProcessor.Verify(x => x.ProcessMessagesAsync( It.IsAny>(), "conversation-from-props", Activity.DownloadText, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task ProcessAgentContentAsync_GeneratesThreadId_WhenNotProvidedAsync() { // Arrange var messages = new List { new(ChatRole.User, "Test message") }; var expectedResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Response")); var mockAgent = new Mock(); mockAgent.Protected() .Setup>("RunCoreAsync", ItExpr.IsAny>(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(expectedResponse); string? capturedSessionId = null; this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback, string, Activity, PurviewSettings, string, CancellationToken>( (_, threadId, _, _, _, _) => capturedSessionId = threadId) .ReturnsAsync((false, "user-123")); // Act var result = await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); // Assert Assert.NotNull(result); Assert.NotNull(capturedSessionId); Assert.True(Guid.TryParse(capturedSessionId, out _), "Generated session ID should be a valid GUID"); } [Fact] public async Task ProcessAgentContentAsync_PassesResolvedUserId_ToResponseProcessingAsync() { // Arrange var messages = new List { new(ChatRole.User, "Test message") }; var mockAgent = new Mock(); var innerResponse = new AgentResponse(new ChatMessage(ChatRole.Assistant, "Response")); mockAgent.Protected() .Setup>("RunCoreAsync", ItExpr.IsAny>(), ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(innerResponse); var callCount = 0; string? firstCallUserId = null; string? secondCallUserId = null; this._mockProcessor.Setup(x => x.ProcessMessagesAsync( It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback, string, Activity, PurviewSettings, string, CancellationToken>( (_, _, _, _, userId, _) => { if (callCount == 0) { firstCallUserId = userId; } else if (callCount == 1) { secondCallUserId = userId; } callCount++; }) .ReturnsAsync((false, "resolved-user-456")); // Act await this._wrapper.ProcessAgentContentAsync(messages, null, null, mockAgent.Object, CancellationToken.None); // Assert Assert.Null(firstCallUserId); // First call (prompt) should have null userId Assert.Equal("resolved-user-456", secondCallUserId); // Second call (response) should have resolved userId from first call } #endregion public void Dispose() { this._wrapper.Dispose(); } }