Files
ColaFlow/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/Services/McpNotificationServiceTests.cs
Yaojia Wang 9ccd3284fb feat(backend): Implement SignalR Real-Time Notifications for MCP - Story 5.12
Implemented comprehensive real-time notification system using SignalR to notify
AI agents and users about PendingChange status updates.

Key Features Implemented:
- McpNotificationHub with Subscribe/Unsubscribe methods
- Real-time notifications for all PendingChange lifecycle events
- Tenant-based isolation for multi-tenancy security
- Notification DTOs for structured message formats
- Domain event handlers for automatic notification sending
- Comprehensive unit tests for notification service and handlers
- Client integration guide with examples for TypeScript, React, and Python

Components Created:
1. SignalR Hub:
   - McpNotificationHub.cs - Central hub for MCP notifications

2. Notification DTOs:
   - PendingChangeNotification.cs (base class)
   - PendingChangeCreatedNotification.cs
   - PendingChangeApprovedNotification.cs
   - PendingChangeRejectedNotification.cs
   - PendingChangeAppliedNotification.cs
   - PendingChangeExpiredNotification.cs

3. Notification Service:
   - IMcpNotificationService.cs (interface)
   - McpNotificationService.cs (implementation using SignalR)

4. Event Handlers (send notifications):
   - PendingChangeCreatedNotificationHandler.cs
   - PendingChangeApprovedNotificationHandler.cs
   - PendingChangeRejectedNotificationHandler.cs
   - PendingChangeAppliedNotificationHandler.cs
   - PendingChangeExpiredNotificationHandler.cs

5. Tests:
   - McpNotificationServiceTests.cs - Unit tests for notification service
   - PendingChangeCreatedNotificationHandlerTests.cs
   - PendingChangeApprovedNotificationHandlerTests.cs

6. Documentation:
   - signalr-mcp-client-guide.md - Comprehensive client integration guide

Technical Details:
- Hub endpoint: /hubs/mcp-notifications
- Authentication: JWT token via query string (?access_token=xxx)
- Tenant isolation: Automatic group joining based on tenant ID
- Group subscriptions: Per-pending-change and per-tenant groups
- Notification delivery: < 1 second (real-time)
- Fallback strategy: Polling if WebSocket unavailable

Architecture Benefits:
- Decoupled design using domain events
- Notification failures don't break main flow
- Scalable (supports Redis backplane for multi-instance)
- Type-safe notification payloads
- Tenant isolation built-in

Story: Phase 3 - Tools & Diff Preview
Priority: P0 CRITICAL
Story Points: 3
Completion: 100%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:21:08 +01:00

234 lines
7.8 KiB
C#

using ColaFlow.API.Hubs;
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
using ColaFlow.Modules.Mcp.Infrastructure.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace ColaFlow.Modules.Mcp.Tests.Services;
public class McpNotificationServiceTests
{
private readonly Mock<IHubContext<McpNotificationHub>> _mockHubContext;
private readonly Mock<ILogger<McpNotificationService>> _mockLogger;
private readonly Mock<IHubClients> _mockClients;
private readonly Mock<IClientProxy> _mockClientProxy;
private readonly McpNotificationService _service;
public McpNotificationServiceTests()
{
_mockHubContext = new Mock<IHubContext<McpNotificationHub>>();
_mockLogger = new Mock<ILogger<McpNotificationService>>();
_mockClients = new Mock<IHubClients>();
_mockClientProxy = new Mock<IClientProxy>();
_mockHubContext.Setup(h => h.Clients).Returns(_mockClients.Object);
_service = new McpNotificationService(_mockHubContext.Object, _mockLogger.Object);
}
[Fact]
public async Task NotifyPendingChangeCreatedAsync_SendsNotificationToCorrectGroups()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var notification = new PendingChangeCreatedNotification
{
NotificationType = "PendingChangeCreated",
PendingChangeId = pendingChangeId,
ToolName = "create_epic",
EntityType = "Epic",
Operation = "CREATE",
Summary = "Create Epic: Test Epic",
TenantId = tenantId
};
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
.Returns(_mockClientProxy.Object);
// Act
await _service.NotifyPendingChangeCreatedAsync(notification);
// Assert
_mockClients.Verify(
c => c.Groups($"pending-change-{pendingChangeId}", $"tenant-{tenantId}"),
Times.Once);
_mockClientProxy.Verify(
p => p.SendCoreAsync(
"PendingChangeCreated",
It.Is<object[]>(args => args.Length == 1 && args[0] == notification),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task NotifyPendingChangeApprovedAsync_SendsNotificationWithCorrectData()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var approvedBy = Guid.NewGuid();
var entityId = Guid.NewGuid();
var notification = new PendingChangeApprovedNotification
{
NotificationType = "PendingChangeApproved",
PendingChangeId = pendingChangeId,
ToolName = "create_epic",
EntityType = "Epic",
Operation = "CREATE",
EntityId = entityId,
ApprovedBy = approvedBy,
ExecutionResult = "Epic created: Test Epic",
TenantId = tenantId
};
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
.Returns(_mockClientProxy.Object);
// Act
await _service.NotifyPendingChangeApprovedAsync(notification);
// Assert
_mockClientProxy.Verify(
p => p.SendCoreAsync(
"PendingChangeApproved",
It.Is<object[]>(args =>
args.Length == 1 &&
((PendingChangeApprovedNotification)args[0]).ApprovedBy == approvedBy &&
((PendingChangeApprovedNotification)args[0]).EntityId == entityId),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task NotifyPendingChangeRejectedAsync_SendsNotificationWithReason()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var rejectedBy = Guid.NewGuid();
var reason = "Epic name is too vague";
var notification = new PendingChangeRejectedNotification
{
NotificationType = "PendingChangeRejected",
PendingChangeId = pendingChangeId,
ToolName = "create_epic",
Reason = reason,
RejectedBy = rejectedBy,
TenantId = tenantId
};
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
.Returns(_mockClientProxy.Object);
// Act
await _service.NotifyPendingChangeRejectedAsync(notification);
// Assert
_mockClientProxy.Verify(
p => p.SendCoreAsync(
"PendingChangeRejected",
It.Is<object[]>(args =>
args.Length == 1 &&
((PendingChangeRejectedNotification)args[0]).Reason == reason &&
((PendingChangeRejectedNotification)args[0]).RejectedBy == rejectedBy),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task NotifyPendingChangeAppliedAsync_SendsNotificationWithResult()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var result = "Epic created: abc123 - Test Epic";
var notification = new PendingChangeAppliedNotification
{
NotificationType = "PendingChangeApplied",
PendingChangeId = pendingChangeId,
ToolName = "create_epic",
Result = result,
AppliedAt = DateTime.UtcNow,
TenantId = tenantId
};
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
.Returns(_mockClientProxy.Object);
// Act
await _service.NotifyPendingChangeAppliedAsync(notification);
// Assert
_mockClientProxy.Verify(
p => p.SendCoreAsync(
"PendingChangeApplied",
It.Is<object[]>(args =>
args.Length == 1 &&
((PendingChangeAppliedNotification)args[0]).Result == result),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task NotifyPendingChangeExpiredAsync_SendsNotificationToCorrectGroups()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var notification = new PendingChangeExpiredNotification
{
NotificationType = "PendingChangeExpired",
PendingChangeId = pendingChangeId,
ToolName = "create_epic",
ExpiredAt = DateTime.UtcNow,
TenantId = tenantId
};
_mockClients.Setup(c => c.Groups(It.IsAny<string>(), It.IsAny<string>()))
.Returns(_mockClientProxy.Object);
// Act
await _service.NotifyPendingChangeExpiredAsync(notification);
// Assert
_mockClients.Verify(
c => c.Groups($"pending-change-{pendingChangeId}", $"tenant-{tenantId}"),
Times.Once);
_mockClientProxy.Verify(
p => p.SendCoreAsync(
"PendingChangeExpired",
It.Is<object[]>(args => args.Length == 1),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public void Constructor_ThrowsArgumentNullException_WhenHubContextIsNull()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
new McpNotificationService(null!, _mockLogger.Object));
Assert.Equal("hubContext", exception.ParamName);
}
[Fact]
public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
new McpNotificationService(_mockHubContext.Object, null!));
Assert.Equal("logger", exception.ParamName);
}
}