Files
ColaFlow/colaflow-api/tests/Modules/Mcp/ColaFlow.Modules.Mcp.Tests/EventHandlers/PendingChangeCreatedNotificationHandlerTests.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

154 lines
4.9 KiB
C#

using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
using ColaFlow.Modules.Mcp.Application.EventHandlers;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Entities;
using ColaFlow.Modules.Mcp.Domain.Events;
using ColaFlow.Modules.Mcp.Domain.Repositories;
using ColaFlow.Modules.Mcp.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace ColaFlow.Modules.Mcp.Tests.EventHandlers;
public class PendingChangeCreatedNotificationHandlerTests
{
private readonly Mock<IMcpNotificationService> _mockNotificationService;
private readonly Mock<IPendingChangeRepository> _mockRepository;
private readonly Mock<ILogger<PendingChangeCreatedNotificationHandler>> _mockLogger;
private readonly PendingChangeCreatedNotificationHandler _handler;
public PendingChangeCreatedNotificationHandlerTests()
{
_mockNotificationService = new Mock<IMcpNotificationService>();
_mockRepository = new Mock<IPendingChangeRepository>();
_mockLogger = new Mock<ILogger<PendingChangeCreatedNotificationHandler>>();
_handler = new PendingChangeCreatedNotificationHandler(
_mockNotificationService.Object,
_mockRepository.Object,
_mockLogger.Object);
}
[Fact]
public async Task Handle_SendsNotification_WhenPendingChangeExists()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var apiKeyId = Guid.NewGuid();
var domainEvent = new PendingChangeCreatedEvent(
pendingChangeId,
"create_epic",
"Epic",
"CREATE",
tenantId);
var diff = new DiffPreview(
"CREATE",
"Epic",
null,
null,
null,
"{\"name\":\"Test Epic\"}",
new List<DiffField>());
var pendingChange = PendingChange.Create(
"create_epic",
diff,
tenantId,
apiKeyId,
12);
_mockRepository.Setup(r => r.GetByIdAsync(pendingChangeId, It.IsAny<CancellationToken>()))
.ReturnsAsync(pendingChange);
// Act
await _handler.Handle(domainEvent, CancellationToken.None);
// Assert
_mockNotificationService.Verify(
s => s.NotifyPendingChangeCreatedAsync(
It.Is<PendingChangeCreatedNotification>(n =>
n.PendingChangeId == pendingChangeId &&
n.ToolName == "create_epic" &&
n.EntityType == "Epic" &&
n.Operation == "CREATE" &&
n.TenantId == tenantId),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Handle_DoesNotSendNotification_WhenPendingChangeNotFound()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var domainEvent = new PendingChangeCreatedEvent(
pendingChangeId,
"create_epic",
"Epic",
"CREATE",
tenantId);
_mockRepository.Setup(r => r.GetByIdAsync(pendingChangeId, It.IsAny<CancellationToken>()))
.ReturnsAsync((PendingChange?)null);
// Act
await _handler.Handle(domainEvent, CancellationToken.None);
// Assert
_mockNotificationService.Verify(
s => s.NotifyPendingChangeCreatedAsync(
It.IsAny<PendingChangeCreatedNotification>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task Handle_DoesNotThrow_WhenNotificationServiceFails()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var apiKeyId = Guid.NewGuid();
var domainEvent = new PendingChangeCreatedEvent(
pendingChangeId,
"create_epic",
"Epic",
"CREATE",
tenantId);
var diff = new DiffPreview(
"CREATE",
"Epic",
null,
null,
null,
"{\"name\":\"Test Epic\"}",
new List<DiffField>());
var pendingChange = PendingChange.Create(
"create_epic",
diff,
tenantId,
apiKeyId,
12);
_mockRepository.Setup(r => r.GetByIdAsync(pendingChangeId, It.IsAny<CancellationToken>()))
.ReturnsAsync(pendingChange);
_mockNotificationService.Setup(s => s.NotifyPendingChangeCreatedAsync(
It.IsAny<PendingChangeCreatedNotification>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("SignalR connection failed"));
// Act & Assert - Should not throw
await _handler.Handle(domainEvent, CancellationToken.None);
}
}