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>
This commit is contained in:
Yaojia Wang
2025-11-09 18:21:08 +01:00
parent 2fec2df004
commit 9ccd3284fb
21 changed files with 1691 additions and 1 deletions

View File

@@ -0,0 +1,90 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace ColaFlow.API.Hubs;
/// <summary>
/// SignalR Hub for MCP real-time notifications
/// Supports notifying AI agents and users about PendingChange status updates
/// </summary>
[Authorize]
public class McpNotificationHub : BaseHub
{
private readonly ILogger<McpNotificationHub> _logger;
public McpNotificationHub(ILogger<McpNotificationHub> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public override async Task OnConnectedAsync()
{
var connectionId = Context.ConnectionId;
var userId = GetCurrentUserId();
var tenantId = GetCurrentTenantId();
_logger.LogInformation(
"MCP client connected - ConnectionId={ConnectionId}, UserId={UserId}, TenantId={TenantId}",
connectionId, userId, tenantId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var connectionId = Context.ConnectionId;
if (exception != null)
{
_logger.LogError(exception,
"MCP client disconnected with error - ConnectionId={ConnectionId}",
connectionId);
}
else
{
_logger.LogInformation(
"MCP client disconnected - ConnectionId={ConnectionId}",
connectionId);
}
await base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Subscribe to receive notifications for a specific pending change
/// </summary>
/// <param name="pendingChangeId">The pending change ID to subscribe to</param>
public async Task SubscribeToPendingChange(Guid pendingChangeId)
{
var groupName = GetPendingChangeGroupName(pendingChangeId);
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
_logger.LogInformation(
"Client subscribed - ConnectionId={ConnectionId}, GroupName={GroupName}, PendingChangeId={PendingChangeId}",
Context.ConnectionId, groupName, pendingChangeId);
}
/// <summary>
/// Unsubscribe from receiving notifications for a specific pending change
/// </summary>
/// <param name="pendingChangeId">The pending change ID to unsubscribe from</param>
public async Task UnsubscribeFromPendingChange(Guid pendingChangeId)
{
var groupName = GetPendingChangeGroupName(pendingChangeId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
_logger.LogInformation(
"Client unsubscribed - ConnectionId={ConnectionId}, GroupName={GroupName}, PendingChangeId={PendingChangeId}",
Context.ConnectionId, groupName, pendingChangeId);
}
/// <summary>
/// Get the SignalR group name for a pending change
/// </summary>
/// <param name="pendingChangeId">The pending change ID</param>
/// <returns>The group name</returns>
private static string GetPendingChangeGroupName(Guid pendingChangeId)
{
return $"pending-change-{pendingChangeId}";
}
}

View File

@@ -225,6 +225,7 @@ app.MapHealthChecks("/health");
// Map SignalR Hubs (after UseAuthorization)
app.MapHub<ProjectHub>("/hubs/project");
app.MapHub<NotificationHub>("/hubs/notification");
app.MapHub<McpNotificationHub>("/hubs/mcp-notifications");
// ============================================
// Auto-migrate databases in development

View File

@@ -0,0 +1,18 @@
namespace ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
/// <summary>
/// Notification sent when a PendingChange has been successfully applied
/// (after approval and execution)
/// </summary>
public sealed record PendingChangeAppliedNotification : PendingChangeNotification
{
/// <summary>
/// Result of applying the change
/// </summary>
public required string Result { get; init; }
/// <summary>
/// When the change was applied (UTC)
/// </summary>
public required DateTime AppliedAt { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
/// <summary>
/// Notification sent when a PendingChange is approved and executed
/// </summary>
public sealed record PendingChangeApprovedNotification : PendingChangeNotification
{
/// <summary>
/// Type of entity that was changed
/// </summary>
public required string EntityType { get; init; }
/// <summary>
/// Operation that was performed
/// </summary>
public required string Operation { get; init; }
/// <summary>
/// ID of the entity that was created/updated (if applicable)
/// </summary>
public Guid? EntityId { get; init; }
/// <summary>
/// ID of the user who approved the change
/// </summary>
public required Guid ApprovedBy { get; init; }
/// <summary>
/// Result of executing the change (e.g., "Epic created: {id} - {name}")
/// </summary>
public string? ExecutionResult { get; init; }
}

View File

@@ -0,0 +1,22 @@
namespace ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
/// <summary>
/// Notification sent when a new PendingChange is created
/// </summary>
public sealed record PendingChangeCreatedNotification : PendingChangeNotification
{
/// <summary>
/// Type of entity being changed (Epic, Story, Task, etc.)
/// </summary>
public required string EntityType { get; init; }
/// <summary>
/// Operation being performed (CREATE, UPDATE, DELETE)
/// </summary>
public required string Operation { get; init; }
/// <summary>
/// Summary of what will be changed
/// </summary>
public required string Summary { get; init; }
}

View File

@@ -0,0 +1,12 @@
namespace ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
/// <summary>
/// Notification sent when a PendingChange expires (timeout)
/// </summary>
public sealed record PendingChangeExpiredNotification : PendingChangeNotification
{
/// <summary>
/// When the pending change expired (UTC)
/// </summary>
public required DateTime ExpiredAt { get; init; }
}

View File

@@ -0,0 +1,32 @@
namespace ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
/// <summary>
/// Base class for all PendingChange notifications
/// </summary>
public abstract record PendingChangeNotification
{
/// <summary>
/// Type of notification (PendingChangeCreated, PendingChangeApproved, etc.)
/// </summary>
public required string NotificationType { get; init; }
/// <summary>
/// The ID of the pending change
/// </summary>
public required Guid PendingChangeId { get; init; }
/// <summary>
/// The tool that created the pending change
/// </summary>
public required string ToolName { get; init; }
/// <summary>
/// When this notification was generated (UTC)
/// </summary>
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
/// <summary>
/// Tenant ID for multi-tenancy support
/// </summary>
public required Guid TenantId { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
/// <summary>
/// Notification sent when a PendingChange is rejected
/// </summary>
public sealed record PendingChangeRejectedNotification : PendingChangeNotification
{
/// <summary>
/// Reason for rejection
/// </summary>
public required string Reason { get; init; }
/// <summary>
/// ID of the user who rejected the change
/// </summary>
public required Guid RejectedBy { get; init; }
}

View File

@@ -0,0 +1,60 @@
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// <summary>
/// Event handler that sends SignalR notifications when a PendingChange is applied
/// </summary>
public class PendingChangeAppliedNotificationHandler : INotificationHandler<PendingChangeAppliedEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly ILogger<PendingChangeAppliedNotificationHandler> _logger;
public PendingChangeAppliedNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeAppliedNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task Handle(PendingChangeAppliedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Handling PendingChangeAppliedEvent for notification - PendingChangeId={PendingChangeId}, Result={Result}",
notification.PendingChangeId, notification.Result);
try
{
// Create notification DTO
var notificationDto = new PendingChangeAppliedNotification
{
NotificationType = "PendingChangeApplied",
PendingChangeId = notification.PendingChangeId,
ToolName = notification.ToolName,
Result = notification.Result,
AppliedAt = DateTime.UtcNow,
TenantId = notification.TenantId,
Timestamp = DateTime.UtcNow
};
// Send notification via SignalR
await _notificationService.NotifyPendingChangeAppliedAsync(notificationDto, cancellationToken);
_logger.LogInformation(
"PendingChangeApplied notification sent successfully - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send PendingChangeApplied notification - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
// Don't rethrow - notification failure shouldn't break the main flow
}
}
}

View File

@@ -0,0 +1,63 @@
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// <summary>
/// Event handler that sends SignalR notifications when a PendingChange is approved
/// Runs in parallel with PendingChangeApprovedEventHandler (which executes the change)
/// </summary>
public class PendingChangeApprovedNotificationHandler : INotificationHandler<PendingChangeApprovedEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly ILogger<PendingChangeApprovedNotificationHandler> _logger;
public PendingChangeApprovedNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeApprovedNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task Handle(PendingChangeApprovedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Handling PendingChangeApprovedEvent for notification - PendingChangeId={PendingChangeId}, EntityType={EntityType}",
notification.PendingChangeId, notification.Diff.EntityType);
try
{
// Create notification DTO
var notificationDto = new PendingChangeApprovedNotification
{
NotificationType = "PendingChangeApproved",
PendingChangeId = notification.PendingChangeId,
ToolName = notification.ToolName,
EntityType = notification.Diff.EntityType,
Operation = notification.Diff.Operation,
EntityId = notification.Diff.EntityId,
ApprovedBy = notification.ApprovedBy,
TenantId = notification.TenantId,
Timestamp = DateTime.UtcNow
};
// Send notification via SignalR
await _notificationService.NotifyPendingChangeApprovedAsync(notificationDto, cancellationToken);
_logger.LogInformation(
"PendingChangeApproved notification sent successfully - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send PendingChangeApproved notification - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
// Don't rethrow - notification failure shouldn't break the main flow
}
}
}

View File

@@ -0,0 +1,76 @@
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
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 MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// <summary>
/// Event handler that sends SignalR notifications when a PendingChange is created
/// </summary>
public class PendingChangeCreatedNotificationHandler : INotificationHandler<PendingChangeCreatedEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly IPendingChangeRepository _repository;
private readonly ILogger<PendingChangeCreatedNotificationHandler> _logger;
public PendingChangeCreatedNotificationHandler(
IMcpNotificationService notificationService,
IPendingChangeRepository repository,
ILogger<PendingChangeCreatedNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task Handle(PendingChangeCreatedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Handling PendingChangeCreatedEvent - PendingChangeId={PendingChangeId}, EntityType={EntityType}, Operation={Operation}",
notification.PendingChangeId, notification.EntityType, notification.Operation);
try
{
// Get PendingChange for summary
var pendingChange = await _repository.GetByIdAsync(notification.PendingChangeId, cancellationToken);
if (pendingChange == null)
{
_logger.LogWarning(
"PendingChange not found - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
return;
}
// Create notification DTO
var notificationDto = new PendingChangeCreatedNotification
{
NotificationType = "PendingChangeCreated",
PendingChangeId = notification.PendingChangeId,
ToolName = notification.ToolName,
EntityType = notification.EntityType,
Operation = notification.Operation,
Summary = pendingChange.GetSummary(),
TenantId = notification.TenantId,
Timestamp = DateTime.UtcNow
};
// Send notification via SignalR
await _notificationService.NotifyPendingChangeCreatedAsync(notificationDto, cancellationToken);
_logger.LogInformation(
"PendingChangeCreated notification sent successfully - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send PendingChangeCreated notification - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
// Don't rethrow - notification failure shouldn't break the main flow
}
}
}

View File

@@ -0,0 +1,59 @@
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// <summary>
/// Event handler that sends SignalR notifications when a PendingChange expires
/// </summary>
public class PendingChangeExpiredNotificationHandler : INotificationHandler<PendingChangeExpiredEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly ILogger<PendingChangeExpiredNotificationHandler> _logger;
public PendingChangeExpiredNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeExpiredNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task Handle(PendingChangeExpiredEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Handling PendingChangeExpiredEvent for notification - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
try
{
// Create notification DTO
var notificationDto = new PendingChangeExpiredNotification
{
NotificationType = "PendingChangeExpired",
PendingChangeId = notification.PendingChangeId,
ToolName = notification.ToolName,
TenantId = notification.TenantId,
ExpiredAt = DateTime.UtcNow,
Timestamp = DateTime.UtcNow
};
// Send notification via SignalR
await _notificationService.NotifyPendingChangeExpiredAsync(notificationDto, cancellationToken);
_logger.LogInformation(
"PendingChangeExpired notification sent successfully - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send PendingChangeExpired notification - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
// Don't rethrow - notification failure shouldn't break the main flow
}
}
}

View File

@@ -0,0 +1,60 @@
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Events;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Mcp.Application.EventHandlers;
/// <summary>
/// Event handler that sends SignalR notifications when a PendingChange is rejected
/// </summary>
public class PendingChangeRejectedNotificationHandler : INotificationHandler<PendingChangeRejectedEvent>
{
private readonly IMcpNotificationService _notificationService;
private readonly ILogger<PendingChangeRejectedNotificationHandler> _logger;
public PendingChangeRejectedNotificationHandler(
IMcpNotificationService notificationService,
ILogger<PendingChangeRejectedNotificationHandler> logger)
{
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task Handle(PendingChangeRejectedEvent notification, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Handling PendingChangeRejectedEvent for notification - PendingChangeId={PendingChangeId}, Reason={Reason}",
notification.PendingChangeId, notification.Reason);
try
{
// Create notification DTO
var notificationDto = new PendingChangeRejectedNotification
{
NotificationType = "PendingChangeRejected",
PendingChangeId = notification.PendingChangeId,
ToolName = notification.ToolName,
Reason = notification.Reason,
RejectedBy = notification.RejectedBy,
TenantId = notification.TenantId,
Timestamp = DateTime.UtcNow
};
// Send notification via SignalR
await _notificationService.NotifyPendingChangeRejectedAsync(notificationDto, cancellationToken);
_logger.LogInformation(
"PendingChangeRejected notification sent successfully - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send PendingChangeRejected notification - PendingChangeId={PendingChangeId}",
notification.PendingChangeId);
// Don't rethrow - notification failure shouldn't break the main flow
}
}
}

View File

@@ -0,0 +1,44 @@
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
namespace ColaFlow.Modules.Mcp.Application.Services;
/// <summary>
/// Service for sending real-time notifications to MCP clients via SignalR
/// </summary>
public interface IMcpNotificationService
{
/// <summary>
/// Notify that a new PendingChange was created
/// </summary>
Task NotifyPendingChangeCreatedAsync(
PendingChangeCreatedNotification notification,
CancellationToken cancellationToken = default);
/// <summary>
/// Notify that a PendingChange was approved
/// </summary>
Task NotifyPendingChangeApprovedAsync(
PendingChangeApprovedNotification notification,
CancellationToken cancellationToken = default);
/// <summary>
/// Notify that a PendingChange was rejected
/// </summary>
Task NotifyPendingChangeRejectedAsync(
PendingChangeRejectedNotification notification,
CancellationToken cancellationToken = default);
/// <summary>
/// Notify that a PendingChange was applied successfully
/// </summary>
Task NotifyPendingChangeAppliedAsync(
PendingChangeAppliedNotification notification,
CancellationToken cancellationToken = default);
/// <summary>
/// Notify that a PendingChange expired
/// </summary>
Task NotifyPendingChangeExpiredAsync(
PendingChangeExpiredNotification notification,
CancellationToken cancellationToken = default);
}

View File

@@ -7,6 +7,7 @@ using ColaFlow.Modules.Mcp.Infrastructure.BackgroundServices;
using ColaFlow.Modules.Mcp.Infrastructure.Middleware;
using ColaFlow.Modules.Mcp.Infrastructure.Persistence;
using ColaFlow.Modules.Mcp.Infrastructure.Persistence.Repositories;
using ColaFlow.Modules.Mcp.Infrastructure.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -44,6 +45,9 @@ public static class McpServiceExtensions
services.AddScoped<IMcpApiKeyService, McpApiKeyService>();
services.AddScoped<IPendingChangeService, PendingChangeService>();
// Register notification service (SignalR real-time notifications)
services.AddScoped<IMcpNotificationService, McpNotificationService>();
// Register background services
services.AddHostedService<PendingChangeExpirationBackgroundService>();

View File

@@ -0,0 +1,135 @@
using ColaFlow.API.Hubs;
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
using ColaFlow.Modules.Mcp.Application.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace ColaFlow.Modules.Mcp.Infrastructure.Services;
/// <summary>
/// Implementation of IMcpNotificationService using SignalR
/// </summary>
public class McpNotificationService : IMcpNotificationService
{
private readonly IHubContext<McpNotificationHub> _hubContext;
private readonly ILogger<McpNotificationService> _logger;
public McpNotificationService(
IHubContext<McpNotificationHub> hubContext,
ILogger<McpNotificationService> logger)
{
_hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task NotifyPendingChangeCreatedAsync(
PendingChangeCreatedNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeCreated notification - PendingChangeId={PendingChangeId}, EntityType={EntityType}, Operation={Operation}",
notification.PendingChangeId, notification.EntityType, notification.Operation);
// Send to both: specific pending change subscribers AND all tenant members
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeCreated", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeCreated notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
public async Task NotifyPendingChangeApprovedAsync(
PendingChangeApprovedNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeApproved notification - PendingChangeId={PendingChangeId}, EntityType={EntityType}, ApprovedBy={ApprovedBy}",
notification.PendingChangeId, notification.EntityType, notification.ApprovedBy);
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeApproved", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeApproved notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
public async Task NotifyPendingChangeRejectedAsync(
PendingChangeRejectedNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeRejected notification - PendingChangeId={PendingChangeId}, Reason={Reason}, RejectedBy={RejectedBy}",
notification.PendingChangeId, notification.Reason, notification.RejectedBy);
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeRejected", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeRejected notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
public async Task NotifyPendingChangeAppliedAsync(
PendingChangeAppliedNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeApplied notification - PendingChangeId={PendingChangeId}, Result={Result}",
notification.PendingChangeId, notification.Result);
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeApplied", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeApplied notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
public async Task NotifyPendingChangeExpiredAsync(
PendingChangeExpiredNotification notification,
CancellationToken cancellationToken = default)
{
var groupName = GetPendingChangeGroupName(notification.PendingChangeId);
var tenantGroupName = GetTenantGroupName(notification.TenantId);
_logger.LogInformation(
"Sending PendingChangeExpired notification - PendingChangeId={PendingChangeId}, ExpiredAt={ExpiredAt}",
notification.PendingChangeId, notification.ExpiredAt);
await _hubContext.Clients
.Groups(groupName, tenantGroupName)
.SendAsync("PendingChangeExpired", notification, cancellationToken);
_logger.LogDebug(
"PendingChangeExpired notification sent - Groups=[{GroupName}, {TenantGroupName}]",
groupName, tenantGroupName);
}
private static string GetPendingChangeGroupName(Guid pendingChangeId)
{
return $"pending-change-{pendingChangeId}";
}
private static string GetTenantGroupName(Guid tenantId)
{
return $"tenant-{tenantId}";
}
}

View File

@@ -0,0 +1,103 @@
using ColaFlow.Modules.Mcp.Application.DTOs.Notifications;
using ColaFlow.Modules.Mcp.Application.EventHandlers;
using ColaFlow.Modules.Mcp.Application.Services;
using ColaFlow.Modules.Mcp.Domain.Events;
using ColaFlow.Modules.Mcp.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace ColaFlow.Modules.Mcp.Tests.EventHandlers;
public class PendingChangeApprovedNotificationHandlerTests
{
private readonly Mock<IMcpNotificationService> _mockNotificationService;
private readonly Mock<ILogger<PendingChangeApprovedNotificationHandler>> _mockLogger;
private readonly PendingChangeApprovedNotificationHandler _handler;
public PendingChangeApprovedNotificationHandlerTests()
{
_mockNotificationService = new Mock<IMcpNotificationService>();
_mockLogger = new Mock<ILogger<PendingChangeApprovedNotificationHandler>>();
_handler = new PendingChangeApprovedNotificationHandler(
_mockNotificationService.Object,
_mockLogger.Object);
}
[Fact]
public async Task Handle_SendsNotification_WithCorrectData()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var approvedBy = Guid.NewGuid();
var entityId = Guid.NewGuid();
var diff = new DiffPreview(
"CREATE",
"Epic",
entityId,
null,
null,
"{\"name\":\"Test Epic\"}",
new List<DiffField>());
var domainEvent = new PendingChangeApprovedEvent(
pendingChangeId,
"create_epic",
diff,
approvedBy,
tenantId);
// Act
await _handler.Handle(domainEvent, CancellationToken.None);
// Assert
_mockNotificationService.Verify(
s => s.NotifyPendingChangeApprovedAsync(
It.Is<PendingChangeApprovedNotification>(n =>
n.PendingChangeId == pendingChangeId &&
n.ToolName == "create_epic" &&
n.EntityType == "Epic" &&
n.Operation == "CREATE" &&
n.EntityId == entityId &&
n.ApprovedBy == approvedBy &&
n.TenantId == tenantId),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Handle_DoesNotThrow_WhenNotificationServiceFails()
{
// Arrange
var pendingChangeId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var approvedBy = Guid.NewGuid();
var diff = new DiffPreview(
"CREATE",
"Epic",
null,
null,
null,
"{\"name\":\"Test Epic\"}",
new List<DiffField>());
var domainEvent = new PendingChangeApprovedEvent(
pendingChangeId,
"create_epic",
diff,
approvedBy,
tenantId);
_mockNotificationService.Setup(s => s.NotifyPendingChangeApprovedAsync(
It.IsAny<PendingChangeApprovedNotification>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("SignalR connection failed"));
// Act & Assert - Should not throw
await _handler.Handle(domainEvent, CancellationToken.None);
}
}

View File

@@ -0,0 +1,153 @@
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);
}
}

View File

@@ -0,0 +1,233 @@
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);
}
}