feat: extract channel management to IChannelService and implement CRUD operations

This commit is contained in:
HueByte
2026-02-21 17:16:41 +01:00
parent 75491d65e2
commit 0d959e317e
13 changed files with 402 additions and 203 deletions
+15 -2
View File
@@ -57,10 +57,23 @@
- **Fixed: `JoinedChannels` race condition** — `IrcClientConnection.JoinedChannels` replaced with thread-safe methods (`JoinChannel`, `LeaveChannel`, `IsInChannel`, `GetJoinedChannels`) using lock synchronization; prevents crashes when broadcaster threads read while the command handler writes - **Fixed: `JoinedChannels` race condition** — `IrcClientConnection.JoinedChannels` replaced with thread-safe methods (`JoinChannel`, `LeaveChannel`, `IsInChannel`, `GetJoinedChannels`) using lock synchronization; prevents crashes when broadcaster threads read while the command handler writes
- **Fixed: `RequireRegistered` fire-and-forget** — converted from sync `bool` to `async Task<bool>` (`RequireRegisteredAsync`) so the error reply is properly awaited before the handler returns - **Fixed: `RequireRegistered` fire-and-forget** — converted from sync `bool` to `async Task<bool>` (`RequireRegisteredAsync`) so the error reply is properly awaited before the handler returns
### ChannelService Extraction
- Extracted channel management logic from `ChannelsController` and `ChatService` into a dedicated `IChannelService` / `ChannelService`
- `ChannelsController` is now a thin adapter — delegates CRUD operations to `IChannelService` and maps `ChannelError` to HTTP status codes
- `ChatService.JoinChannelAsync` delegates channel validation + membership to `IChannelService.EnsureChannelMembershipAsync()`
- New `ChannelOperationResult` result type with `ChannelError` enum for typed error handling across service boundaries
- IRC gateway uses `IChannelService` for topic queries and channel listing (instead of `IChatService`)
### EchoHub Branding
- Status bar "EchoHub" text now uses golden color (218, 165, 32)
## Infrastructure ## Infrastructure
- Audio MIME types in `FilesController` (mp3, wav, ogg, flac, aac, m4a, wma) - Audio MIME types in `FilesController` (mp3, wav, ogg, flac, aac, m4a, wma)
- `FakeChannelService` test helper added for IRC unit tests
- Test suites: ChatLine, CommandHandler, DataMigrationService, FileValidationHelper, ImageToAsciiService, IrcMessageFormatter, JwtTokenService, LinkEmbedService - Test suites: ChatLine, CommandHandler, DataMigrationService, FileValidationHelper, ImageToAsciiService, IrcMessageFormatter, JwtTokenService, LinkEmbedService
- IRC abstraction layer test suite: IrcMessage parsing, IrcMessageFormatter, IrcClientConnection, IrcCommandHandler, IrcBroadcaster - IRC abstraction layer test suite: IrcMessage parsing, IrcMessageFormatter, IrcClientConnection, IrcCommandHandler, IrcBroadcaster
- Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeEncryptionService` for IRC unit testing without network I/O - Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeChannelService`, `FakeEncryptionService` for IRC unit testing without network I/O
- 214 total tests - 346 total tests
+1 -1
View File
@@ -709,7 +709,7 @@ public sealed class MainWindow : Runnable
private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.Transparent); private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.Transparent);
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.Transparent); private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.Transparent);
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.Transparent); private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.Transparent);
private static readonly Attribute StatusBrandAttr = new(new Color(100, 160, 255), Color.Transparent); private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.Transparent);
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e) private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
{ {
@@ -0,0 +1,22 @@
using EchoHub.Core.DTOs;
namespace EchoHub.Core.Contracts;
public interface IChannelService
{
// Channel CRUD
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic);
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
// Channel queries
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
// Membership
Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName);
}
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
+1 -5
View File
@@ -25,12 +25,8 @@ public interface IChatService
Task BroadcastMessageAsync(string channelName, MessageDto message); Task BroadcastMessageAsync(string channelName, MessageDto message);
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null); Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
// Query operations (used by IRC gateway for WHOIS, TOPIC, LIST, AUTH) // Query operations (used by IRC gateway for WHOIS, AUTH)
Task<UserProfileDto?> GetUserProfileAsync(string username); Task<UserProfileDto?> GetUserProfileAsync(string username);
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<List<string>> GetChannelsForUserAsync(string username); Task<List<string>> GetChannelsForUserAsync(string username);
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
} }
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
+17
View File
@@ -3,3 +3,20 @@ namespace EchoHub.Core.DTOs;
public record ErrorResponse(string Error, string? Detail = null); public record ErrorResponse(string Error, string? Detail = null);
public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit); public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit);
public enum ChannelError
{
ValidationFailed,
AlreadyExists,
NotFound,
Forbidden,
Protected
}
public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, string? ErrorMessage)
{
public bool IsSuccess => Error is null;
public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null);
public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message);
}
+5 -2
View File
@@ -12,6 +12,7 @@ public sealed class IrcCommandHandler
private readonly IrcClientConnection _conn; private readonly IrcClientConnection _conn;
private readonly IrcOptions _options; private readonly IrcOptions _options;
private readonly IChatService _chatService; private readonly IChatService _chatService;
private readonly IChannelService _channelService;
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
private readonly ILogger _logger; private readonly ILogger _logger;
@@ -21,12 +22,14 @@ public sealed class IrcCommandHandler
IrcClientConnection conn, IrcClientConnection conn,
IrcOptions options, IrcOptions options,
IChatService chatService, IChatService chatService,
IChannelService channelService,
IMessageEncryptionService encryption, IMessageEncryptionService encryption,
ILogger logger) ILogger logger)
{ {
_conn = conn; _conn = conn;
_options = options; _options = options;
_chatService = chatService; _chatService = chatService;
_channelService = channelService;
_encryption = encryption; _encryption = encryption;
_logger = logger; _logger = logger;
} }
@@ -481,7 +484,7 @@ public sealed class IrcCommandHandler
private async Task SendChannelTopicAsync(string channelName) private async Task SendChannelTopicAsync(string channelName)
{ {
var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName); var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName);
if (!exists) return; if (!exists) return;
@@ -587,7 +590,7 @@ public sealed class IrcCommandHandler
{ {
if (!await RequireRegisteredAsync()) return; if (!await RequireRegisteredAsync()) return;
var channels = await _chatService.GetChannelListAsync(); var channels = await _channelService.GetChannelListAsync();
foreach (var ch in channels) foreach (var ch in channels)
{ {
+2 -1
View File
@@ -120,9 +120,10 @@ public sealed class IrcGatewayService : BackgroundService
try try
{ {
chatService = _services.GetRequiredService<IChatService>(); chatService = _services.GetRequiredService<IChatService>();
var channelService = _services.GetRequiredService<IChannelService>();
var encryption = _services.GetRequiredService<IMessageEncryptionService>(); var encryption = _services.GetRequiredService<IMessageEncryptionService>();
var handler = new IrcCommandHandler( var handler = new IrcCommandHandler(
connection, _options, chatService, encryption, _logger); connection, _options, chatService, channelService, encryption, _logger);
await handler.RunAsync(ct); await handler.RunAsync(ct);
} }
@@ -8,7 +8,6 @@ using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
@@ -18,6 +17,7 @@ namespace EchoHub.Server.Controllers;
[EnableRateLimiting("general")] [EnableRateLimiting("general")]
public class ChannelsController : ControllerBase public class ChannelsController : ControllerBase
{ {
private readonly IChannelService _channelService;
private readonly EchoHubDbContext _db; private readonly EchoHubDbContext _db;
private readonly FileStorageService _fileStorage; private readonly FileStorageService _fileStorage;
private readonly ImageToAsciiService _asciiService; private readonly ImageToAsciiService _asciiService;
@@ -26,6 +26,7 @@ public class ChannelsController : ControllerBase
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
public ChannelsController( public ChannelsController(
IChannelService channelService,
EchoHubDbContext db, EchoHubDbContext db,
FileStorageService fileStorage, FileStorageService fileStorage,
ImageToAsciiService asciiService, ImageToAsciiService asciiService,
@@ -33,6 +34,7 @@ public class ChannelsController : ControllerBase
IChatService chatService, IChatService chatService,
IMessageEncryptionService encryption) IMessageEncryptionService encryption)
{ {
_channelService = channelService;
_db = db; _db = db;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_asciiService = asciiService; _asciiService = asciiService;
@@ -48,87 +50,29 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
offset = Math.Max(0, offset); offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100); limit = Math.Clamp(limit, 1, 100);
// Ensure #general always exists var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
if (!await _db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)) return Ok(result);
{
_db.Channels.Add(new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
});
await _db.SaveChangesAsync();
}
// Public channels + private channels the user has joined
var query = _db.Channels.Where(c =>
c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
var total = await query.CountAsync();
var channels = await query
.OrderBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id,
c.Name,
c.Topic,
c.IsPublic,
c.Messages.Count,
c.CreatedAt))
.ToListAsync();
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request) public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new ErrorResponse("Channel name is required."));
var channelName = request.Name.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
if (await _db.Channels.AnyAsync(c => c.Name == channelName))
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channel = new Channel var result = await _channelService.CreateChannelAsync(
{ Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
Id = Guid.NewGuid(), if (!result.IsSuccess)
Name = channelName, return MapChannelError(result);
Topic = request.Topic?.Trim(),
IsPublic = request.IsPublic,
CreatedByUserId = Guid.Parse(userIdClaim),
};
_db.Channels.Add(channel); if (result.Channel!.IsPublic)
await _chatService.BroadcastChannelUpdatedAsync(result.Channel);
// Creator automatically becomes a member return Created($"/api/channels/{result.Channel.Name}", result.Channel);
_db.ChannelMemberships.Add(new ChannelMembership
{
UserId = Guid.Parse(userIdClaim),
ChannelId = channel.Id,
});
await _db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
if (channel.IsPublic)
await _chatService.BroadcastChannelUpdatedAsync(dto);
return Created($"/api/channels/{channelName}", dto);
} }
[HttpPut("{channel}/topic")] [HttpPut("{channel}/topic")]
@@ -138,26 +82,13 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim(); var result = await _channelService.UpdateTopicAsync(
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); Guid.Parse(userIdClaim), channel, request.Topic);
if (!result.IsSuccess)
return MapChannelError(result);
if (dbChannel is null) await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim());
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return Ok(result.Channel);
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
dbChannel.Topic = request.Topic?.Trim();
await _db.SaveChangesAsync();
var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
await _chatService.BroadcastChannelUpdatedAsync(dto, channelName);
return Ok(dto);
} }
[HttpDelete("{channel}")] [HttpDelete("{channel}")]
@@ -167,23 +98,9 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim(); var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel);
if (!result.IsSuccess)
if (channelName == HubConstants.DefaultChannel) return MapChannelError(result);
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
var userId = Guid.Parse(userIdClaim);
var caller = await _db.Users.FindAsync(userId);
if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin))
return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel."));
_db.Channels.Remove(dbChannel);
await _db.SaveChangesAsync();
return NoContent(); return NoContent();
} }
@@ -203,8 +120,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channelDto = await _channelService.GetChannelByNameAsync(channelName);
if (dbChannel is null) if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0) if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
@@ -250,7 +167,7 @@ public class ChannelsController : ControllerBase
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName, AttachmentFileName = file.FileName,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id, ChannelId = channelDto.Id,
SenderUserId = userId, SenderUserId = userId,
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
@@ -290,8 +207,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channelDto = await _channelService.GetChannelByNameAsync(channelName);
if (dbChannel is null) if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (string.IsNullOrWhiteSpace(request.Url)) if (string.IsNullOrWhiteSpace(request.Url))
@@ -370,7 +287,7 @@ public class ChannelsController : ControllerBase
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName, AttachmentFileName = fileName,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id, ChannelId = channelDto.Id,
SenderUserId = userId, SenderUserId = userId,
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
@@ -394,4 +311,14 @@ public class ChannelsController : ControllerBase
return Ok(messageDto); return Ok(messageDto);
} }
private IActionResult MapChannelError(ChannelOperationResult result) => result.Error switch
{
ChannelError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
ChannelError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
ChannelError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
ChannelError.Forbidden => StatusCode(403, new ErrorResponse(result.ErrorMessage!)),
ChannelError.Protected => BadRequest(new ErrorResponse(result.ErrorMessage!)),
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
};
} }
+1
View File
@@ -115,6 +115,7 @@ while (true)
// ── Chat Service + Broadcasters ───────────────────────────────────── // ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>(); builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IChannelService, ChannelService>();
builder.Services.AddSingleton<IChatService, ChatService>(); builder.Services.AddSingleton<IChatService, ChatService>();
// ── IRC Gateway (optional) ────────────────────────────────────────── // ── IRC Gateway (optional) ──────────────────────────────────────────
@@ -0,0 +1,247 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace EchoHub.Server.Services;
public class ChannelService : IChannelService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presenceTracker;
private readonly ILogger<ChannelService> _logger;
public ChannelService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
ILogger<ChannelService> logger)
{
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_logger = logger;
}
public async Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
await EnsureDefaultChannelAsync(db);
var query = db.Channels.Where(c =>
c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
var total = await query.CountAsync();
var channels = await query
.OrderBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt))
.ToListAsync();
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
}
public async Task<ChannelOperationResult> CreateChannelAsync(
Guid creatorUserId, string name, string? topic, bool isPublic)
{
if (string.IsNullOrWhiteSpace(name))
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required.");
var channelName = name.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
if (await db.Channels.AnyAsync(c => c.Name == channelName))
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = topic?.Trim(),
IsPublic = isPublic,
CreatedByUserId = creatorUserId,
};
db.Channels.Add(channel);
// Creator automatically becomes a member
db.ChannelMemberships.Add(new ChannelMembership
{
UserId = creatorUserId,
ChannelId = channel.Id,
});
await db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
return ChannelOperationResult.Success(dto);
}
public async Task<ChannelOperationResult> UpdateTopicAsync(
Guid callerUserId, string channelName, string? topic)
{
channelName = channelName.ToLowerInvariant().Trim();
if (topic is not null && topic.Length > ValidationConstants.MaxChannelTopicLength)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
$"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
if (dbChannel.CreatedByUserId != callerUserId)
return ChannelOperationResult.Fail(ChannelError.Forbidden, "Only the channel creator can update the topic.");
dbChannel.Topic = topic?.Trim();
await db.SaveChangesAsync();
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
return ChannelOperationResult.Success(dto);
}
public async Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
if (channelName == HubConstants.DefaultChannel)
return ChannelOperationResult.Fail(ChannelError.Protected,
$"The '{HubConstants.DefaultChannel}' channel cannot be deleted.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
var caller = await db.Users.FindAsync(callerUserId);
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
return ChannelOperationResult.Fail(ChannelError.Forbidden,
"Only the channel creator or an admin can delete the channel.");
db.Channels.Remove(dbChannel);
await db.SaveChangesAsync();
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt);
return ChannelOperationResult.Success(dto);
}
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
return channels.Select(c => new ChannelListItem(
c.Name, c.Topic,
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
}
public async Task<ChannelDto?> GetChannelByNameAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
if (c is null) return null;
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt);
}
public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
// Auto-recreate #general if it was somehow removed
if (channelName == HubConstants.DefaultChannel)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
}
else
{
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.");
}
}
var hasMembership = await db.ChannelMemberships
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
if (!hasMembership)
{
db.ChannelMemberships.Add(new ChannelMembership
{
UserId = userId,
ChannelId = channel.Id,
});
await db.SaveChangesAsync();
}
return (true, null);
}
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
{
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
{
db.Channels.Add(new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
});
await db.SaveChangesAsync();
}
}
}
+8 -68
View File
@@ -17,6 +17,7 @@ public class ChatService : IChatService
private readonly IEnumerable<IChatBroadcaster> _broadcasters; private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly LinkEmbedService _embedService; private readonly LinkEmbedService _embedService;
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
private readonly IChannelService _channelService;
private readonly ILogger<ChatService> _logger; private readonly ILogger<ChatService> _logger;
public ChatService( public ChatService(
@@ -25,6 +26,7 @@ public class ChatService : IChatService
IEnumerable<IChatBroadcaster> broadcasters, IEnumerable<IChatBroadcaster> broadcasters,
LinkEmbedService embedService, LinkEmbedService embedService,
IMessageEncryptionService encryption, IMessageEncryptionService encryption,
IChannelService channelService,
ILogger<ChatService> logger) ILogger<ChatService> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
@@ -32,6 +34,7 @@ public class ChatService : IChatService
_broadcasters = broadcasters; _broadcasters = broadcasters;
_embedService = embedService; _embedService = embedService;
_encryption = encryption; _encryption = encryption;
_channelService = channelService;
_logger = logger; _logger = logger;
} }
@@ -95,47 +98,10 @@ public class ChatService : IChatService
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) // Delegate channel validation + membership to ChannelService
return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName);
if (!success)
using var scope = _scopeFactory.CreateScope(); return ([], error);
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
// Auto-recreate #general if it was somehow removed
if (channelName == HubConstants.DefaultChannel)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
}
else
{
return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
}
}
// Persist membership so the channel shows in the user's channel list
var hasMembership = await db.ChannelMemberships
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
if (!hasMembership)
{
db.ChannelMemberships.Add(new ChannelMembership
{
UserId = userId,
ChannelId = channel.Id,
});
await db.SaveChangesAsync();
}
var isNewJoin = _presenceTracker.JoinChannel(username, channelName); var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
@@ -145,7 +111,7 @@ public class ChatService : IChatService
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
} }
var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount); var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
return (history, null); return (history, null);
} }
@@ -354,32 +320,6 @@ public class ChatService : IChatService
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt); user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
} }
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
return channels.Select(c => new ChannelListItem(
c.Name,
c.Topic,
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
}
public Task<List<string>> GetChannelsForUserAsync(string username) public Task<List<string>> GetChannelsForUserAsync(string username)
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username)); => Task.FromResult(_presenceTracker.GetChannelsForUser(username));
@@ -12,10 +12,11 @@ public class IrcCommandHandlerTests
{ {
private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null }; private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null };
private readonly FakeChatService _chatService = new(); private readonly FakeChatService _chatService = new();
private readonly FakeChannelService _channelService = new();
private readonly FakeEncryptionService _encryption = new(); private readonly FakeEncryptionService _encryption = new();
private IrcCommandHandler CreateHandler(IrcClientConnection conn) => private IrcCommandHandler CreateHandler(IrcClientConnection conn) =>
new(conn, _options, _chatService, _encryption, NullLogger.Instance); new(conn, _options, _chatService, _channelService, _encryption, NullLogger.Instance);
private async Task<List<string>> RunAndCapture(string[] inputLines, private async Task<List<string>> RunAndCapture(string[] inputLines,
Action<IrcClientConnection>? setup = null) Action<IrcClientConnection>? setup = null)
@@ -242,7 +243,7 @@ public class IrcCommandHandlerTests
[Fact] [Fact]
public async Task Join_ValidChannel_ConfirmsJoin() public async Task Join_ValidChannel_ConfirmsJoin()
{ {
_chatService.TopicResult = ("Welcome!", true); _channelService.TopicResult = ("Welcome!", true);
var lines = await RunAuthenticated(["JOIN #general"]); var lines = await RunAuthenticated(["JOIN #general"]);
@@ -254,7 +255,7 @@ public class IrcCommandHandlerTests
[Fact] [Fact]
public async Task Join_SendsTopic() public async Task Join_SendsTopic()
{ {
_chatService.TopicResult = ("Welcome to general!", true); _channelService.TopicResult = ("Welcome to general!", true);
var lines = await RunAuthenticated(["JOIN #general"]); var lines = await RunAuthenticated(["JOIN #general"]);
@@ -264,7 +265,7 @@ public class IrcCommandHandlerTests
[Fact] [Fact]
public async Task Join_NoTopic_SendsNoTopicReply() public async Task Join_NoTopic_SendsNoTopicReply()
{ {
_chatService.TopicResult = (null, true); _channelService.TopicResult = (null, true);
var lines = await RunAuthenticated(["JOIN #general"]); var lines = await RunAuthenticated(["JOIN #general"]);
@@ -439,7 +440,7 @@ public class IrcCommandHandlerTests
[Fact] [Fact]
public async Task Topic_Query_ReturnsTopic() public async Task Topic_Query_ReturnsTopic()
{ {
_chatService.TopicResult = ("Chat about everything", true); _channelService.TopicResult = ("Chat about everything", true);
var lines = await RunAuthenticated(["TOPIC #general"]); var lines = await RunAuthenticated(["TOPIC #general"]);
@@ -543,7 +544,7 @@ public class IrcCommandHandlerTests
[Fact] [Fact]
public async Task List_ReturnsChannels() public async Task List_ReturnsChannels()
{ {
_chatService.ChannelListToReturn = _channelService.ChannelListToReturn =
[ [
new("general", "General chat", 5), new("general", "General chat", 5),
new("random", null, 2), new("random", null, 2),
+39 -8
View File
@@ -158,8 +158,6 @@ internal sealed class FakeChatService : IChatService
public string? SendMessageError { get; set; } public string? SendMessageError { get; set; }
public (Guid UserId, string Username)? AuthResult { get; set; } public (Guid UserId, string Username)? AuthResult { get; set; }
public UserProfileDto? ProfileToReturn { get; set; } public UserProfileDto? ProfileToReturn { get; set; }
public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true);
public List<ChannelListItem> ChannelListToReturn { get; set; } = [];
public List<string> ChannelsForUserToReturn { get; set; } = []; public List<string> ChannelsForUserToReturn { get; set; } = [];
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = []; public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
@@ -215,15 +213,48 @@ internal sealed class FakeChatService : IChatService
public Task<UserProfileDto?> GetUserProfileAsync(string username) => public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn); Task.FromResult(ProfileToReturn);
public Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) =>
Task.FromResult(TopicResult);
public Task<List<ChannelListItem>> GetChannelListAsync() =>
Task.FromResult(ChannelListToReturn);
public Task<List<string>> GetChannelsForUserAsync(string username) => public Task<List<string>> GetChannelsForUserAsync(string username) =>
Task.FromResult(ChannelsForUserToReturn); Task.FromResult(ChannelsForUserToReturn);
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) => public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
Task.FromResult(AuthResult); Task.FromResult(AuthResult);
} }
/// <summary>
/// Fake channel service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeChannelService : IChannelService
{
// Configurable results
public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true);
public List<ChannelListItem> ChannelListToReturn { get; set; } = [];
public ChannelDto? ChannelByNameToReturn { get; set; }
public ChannelOperationResult? CreateResult { get; set; }
public ChannelOperationResult? UpdateTopicResult { get; set; }
public ChannelOperationResult? DeleteResult { get; set; }
public (bool Success, string? Error) MembershipResult { get; set; } = (true, null);
public Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) =>
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName) =>
Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) =>
Task.FromResult(TopicResult);
public Task<List<ChannelListItem>> GetChannelListAsync() =>
Task.FromResult(ChannelListToReturn);
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
Task.FromResult(ChannelByNameToReturn);
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
Task.FromResult(MembershipResult);
}