mirror of
https://github.com/Stone-Red-Code/EchoHub.git
synced 2026-09-04 09:06:07 +02:00
Merge pull request #13 from HueByte/dev_channel_logic_update
feat: extract channel management to IChannelService and implement CRU…
This commit is contained in:
@@ -25,16 +25,17 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
||||
- name: Remove submodule NuGet config
|
||||
run: rm -f src/Terminal.Gui/nuget.config
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/EchoHub.slnx --configfile nuget.config
|
||||
|
||||
- name: Check formatting
|
||||
run: dotnet format src/EchoHub.slnx --no-restore --verify-no-changes --verbosity diagnostic
|
||||
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic --exclude src/Terminal.Gui/
|
||||
|
||||
build-and-test:
|
||||
name: Build & Test
|
||||
@@ -45,6 +46,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
||||
- name: Remove submodule NuGet config
|
||||
run: rm -f src/Terminal.Gui/nuget.config
|
||||
|
||||
- name: Check for src/ changes
|
||||
id: changes
|
||||
env:
|
||||
@@ -78,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Restore dependencies
|
||||
if: steps.changes.outputs.src_changed == 'true'
|
||||
run: dotnet restore src/EchoHub.slnx --configfile nuget.config
|
||||
run: dotnet restore src/EchoHub.slnx
|
||||
|
||||
- name: Build
|
||||
if: steps.changes.outputs.src_changed == 'true'
|
||||
|
||||
@@ -19,6 +19,10 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
||||
- name: Remove submodule NuGet config
|
||||
run: rm -f src/Terminal.Gui/nuget.config
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
@@ -34,11 +38,8 @@ jobs:
|
||||
- name: Restore .NET tools
|
||||
run: dotnet tool restore
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/EchoHub.slnx --configfile nuget.config
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build src/EchoHub.slnx --no-restore --configuration Release
|
||||
run: dotnet build src/EchoHub.slnx --configuration Release
|
||||
|
||||
- name: Generate documentation
|
||||
run: dotnet docfx docs/docfx.json
|
||||
|
||||
@@ -18,6 +18,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
|
||||
- name: Remove submodule NuGet config
|
||||
run: rm -f src/Terminal.Gui/nuget.config
|
||||
|
||||
- name: Check for src/ changes
|
||||
id: changes
|
||||
env:
|
||||
@@ -56,41 +60,37 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet restore src/EchoHub.slnx --configfile nuget.config
|
||||
|
||||
- name: Publish Server win-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true --no-restore -o publish/server-win-x64
|
||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64
|
||||
|
||||
- name: Publish Server linux-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true --no-restore -o publish/server-linux-x64
|
||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64
|
||||
|
||||
- name: Publish Server osx-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true --no-restore -o publish/server-osx-x64
|
||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64
|
||||
|
||||
- name: Publish Server osx-arm64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true --no-restore -o publish/server-osx-arm64
|
||||
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64
|
||||
|
||||
- name: Publish Client win-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true --no-restore -o publish/client-win-x64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
|
||||
|
||||
- name: Publish Client linux-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true --no-restore -o publish/client-linux-x64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64
|
||||
|
||||
- name: Publish Client osx-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true --no-restore -o publish/client-osx-x64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64
|
||||
|
||||
- name: Publish Client osx-arm64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true --no-restore -o publish/client-osx-arm64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64
|
||||
|
||||
- name: Zip artifacts
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
|
||||
@@ -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: `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
|
||||
|
||||
- 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
|
||||
- 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
|
||||
- 214 total tests
|
||||
- Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeChannelService`, `FakeEncryptionService` for IRC unit testing without network I/O
|
||||
- 346 total tests
|
||||
|
||||
@@ -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 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 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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
@@ -25,12 +25,8 @@ public interface IChatService
|
||||
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
||||
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<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
||||
Task<List<ChannelListItem>> GetChannelListAsync();
|
||||
Task<List<string>> GetChannelsForUserAsync(string username);
|
||||
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
|
||||
}
|
||||
|
||||
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
|
||||
|
||||
@@ -3,3 +3,20 @@ namespace EchoHub.Core.DTOs;
|
||||
public record ErrorResponse(string Error, string? Detail = null);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class IrcCommandHandler
|
||||
private readonly IrcClientConnection _conn;
|
||||
private readonly IrcOptions _options;
|
||||
private readonly IChatService _chatService;
|
||||
private readonly IChannelService _channelService;
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
@@ -21,12 +22,14 @@ public sealed class IrcCommandHandler
|
||||
IrcClientConnection conn,
|
||||
IrcOptions options,
|
||||
IChatService chatService,
|
||||
IChannelService channelService,
|
||||
IMessageEncryptionService encryption,
|
||||
ILogger logger)
|
||||
{
|
||||
_conn = conn;
|
||||
_options = options;
|
||||
_chatService = chatService;
|
||||
_channelService = channelService;
|
||||
_encryption = encryption;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -481,7 +484,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task SendChannelTopicAsync(string channelName)
|
||||
{
|
||||
var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName);
|
||||
var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName);
|
||||
|
||||
if (!exists) return;
|
||||
|
||||
@@ -587,7 +590,7 @@ public sealed class IrcCommandHandler
|
||||
{
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
|
||||
var channels = await _chatService.GetChannelListAsync();
|
||||
var channels = await _channelService.GetChannelListAsync();
|
||||
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
|
||||
@@ -120,9 +120,10 @@ public sealed class IrcGatewayService : BackgroundService
|
||||
try
|
||||
{
|
||||
chatService = _services.GetRequiredService<IChatService>();
|
||||
var channelService = _services.GetRequiredService<IChannelService>();
|
||||
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
|
||||
var handler = new IrcCommandHandler(
|
||||
connection, _options, chatService, encryption, _logger);
|
||||
connection, _options, chatService, channelService, encryption, _logger);
|
||||
|
||||
await handler.RunAsync(ct);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ using EchoHub.Server.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
@@ -18,6 +17,7 @@ namespace EchoHub.Server.Controllers;
|
||||
[EnableRateLimiting("general")]
|
||||
public class ChannelsController : ControllerBase
|
||||
{
|
||||
private readonly IChannelService _channelService;
|
||||
private readonly EchoHubDbContext _db;
|
||||
private readonly FileStorageService _fileStorage;
|
||||
private readonly ImageToAsciiService _asciiService;
|
||||
@@ -26,6 +26,7 @@ public class ChannelsController : ControllerBase
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
|
||||
public ChannelsController(
|
||||
IChannelService channelService,
|
||||
EchoHubDbContext db,
|
||||
FileStorageService fileStorage,
|
||||
ImageToAsciiService asciiService,
|
||||
@@ -33,6 +34,7 @@ public class ChannelsController : ControllerBase
|
||||
IChatService chatService,
|
||||
IMessageEncryptionService encryption)
|
||||
{
|
||||
_channelService = channelService;
|
||||
_db = db;
|
||||
_fileStorage = fileStorage;
|
||||
_asciiService = asciiService;
|
||||
@@ -48,87 +50,29 @@ public class ChannelsController : ControllerBase
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
offset = Math.Max(0, offset);
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
// Ensure #general always exists
|
||||
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();
|
||||
}
|
||||
|
||||
// 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));
|
||||
var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
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);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channel = new Channel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = channelName,
|
||||
Topic = request.Topic?.Trim(),
|
||||
IsPublic = request.IsPublic,
|
||||
CreatedByUserId = Guid.Parse(userIdClaim),
|
||||
};
|
||||
var result = await _channelService.CreateChannelAsync(
|
||||
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
_db.Channels.Add(channel);
|
||||
if (result.Channel!.IsPublic)
|
||||
await _chatService.BroadcastChannelUpdatedAsync(result.Channel);
|
||||
|
||||
// Creator automatically becomes a member
|
||||
_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);
|
||||
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
|
||||
}
|
||||
|
||||
[HttpPut("{channel}/topic")]
|
||||
@@ -138,26 +82,13 @@ public class ChannelsController : ControllerBase
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
var result = await _channelService.UpdateTopicAsync(
|
||||
Guid.Parse(userIdClaim), channel, request.Topic);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
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);
|
||||
await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim());
|
||||
return Ok(result.Channel);
|
||||
}
|
||||
|
||||
[HttpDelete("{channel}")]
|
||||
@@ -167,23 +98,9 @@ public class ChannelsController : ControllerBase
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
|
||||
if (channelName == HubConstants.DefaultChannel)
|
||||
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();
|
||||
var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
@@ -203,8 +120,8 @@ public class ChannelsController : ControllerBase
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||
|
||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (dbChannel is null)
|
||||
var channelDto = await _channelService.GetChannelByNameAsync(channelName);
|
||||
if (channelDto is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||
@@ -250,7 +167,7 @@ public class ChannelsController : ControllerBase
|
||||
AttachmentUrl = attachmentUrl,
|
||||
AttachmentFileName = file.FileName,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = dbChannel.Id,
|
||||
ChannelId = channelDto.Id,
|
||||
SenderUserId = userId,
|
||||
SenderUsername = usernameClaim,
|
||||
};
|
||||
@@ -290,8 +207,8 @@ public class ChannelsController : ControllerBase
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||
|
||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (dbChannel is null)
|
||||
var channelDto = await _channelService.GetChannelByNameAsync(channelName);
|
||||
if (channelDto is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
@@ -370,7 +287,7 @@ public class ChannelsController : ControllerBase
|
||||
AttachmentUrl = attachmentUrl,
|
||||
AttachmentFileName = fileName,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = dbChannel.Id,
|
||||
ChannelId = channelDto.Id,
|
||||
SenderUserId = userId,
|
||||
SenderUsername = usernameClaim,
|
||||
};
|
||||
@@ -394,4 +311,14 @@ public class ChannelsController : ControllerBase
|
||||
|
||||
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.")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ while (true)
|
||||
|
||||
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
||||
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
||||
builder.Services.AddSingleton<IChannelService, ChannelService>();
|
||||
builder.Services.AddSingleton<IChatService, ChatService>();
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class ChatService : IChatService
|
||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||
private readonly LinkEmbedService _embedService;
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
private readonly IChannelService _channelService;
|
||||
private readonly ILogger<ChatService> _logger;
|
||||
|
||||
public ChatService(
|
||||
@@ -25,6 +26,7 @@ public class ChatService : IChatService
|
||||
IEnumerable<IChatBroadcaster> broadcasters,
|
||||
LinkEmbedService embedService,
|
||||
IMessageEncryptionService encryption,
|
||||
IChannelService channelService,
|
||||
ILogger<ChatService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
@@ -32,6 +34,7 @@ public class ChatService : IChatService
|
||||
_broadcasters = broadcasters;
|
||||
_embedService = embedService;
|
||||
_encryption = encryption;
|
||||
_channelService = channelService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -95,47 +98,10 @@ public class ChatService : IChatService
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return ([], "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 ([], $"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();
|
||||
}
|
||||
// Delegate channel validation + membership to ChannelService
|
||||
var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName);
|
||||
if (!success)
|
||||
return ([], error);
|
||||
|
||||
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
|
||||
|
||||
@@ -145,7 +111,7 @@ public class ChatService : IChatService
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -354,32 +320,6 @@ public class ChatService : IChatService
|
||||
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)
|
||||
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ public class IrcCommandHandlerTests
|
||||
{
|
||||
private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null };
|
||||
private readonly FakeChatService _chatService = new();
|
||||
private readonly FakeChannelService _channelService = new();
|
||||
private readonly FakeEncryptionService _encryption = new();
|
||||
|
||||
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,
|
||||
Action<IrcClientConnection>? setup = null)
|
||||
@@ -242,7 +243,7 @@ public class IrcCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task Join_ValidChannel_ConfirmsJoin()
|
||||
{
|
||||
_chatService.TopicResult = ("Welcome!", true);
|
||||
_channelService.TopicResult = ("Welcome!", true);
|
||||
|
||||
var lines = await RunAuthenticated(["JOIN #general"]);
|
||||
|
||||
@@ -254,7 +255,7 @@ public class IrcCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task Join_SendsTopic()
|
||||
{
|
||||
_chatService.TopicResult = ("Welcome to general!", true);
|
||||
_channelService.TopicResult = ("Welcome to general!", true);
|
||||
|
||||
var lines = await RunAuthenticated(["JOIN #general"]);
|
||||
|
||||
@@ -264,7 +265,7 @@ public class IrcCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task Join_NoTopic_SendsNoTopicReply()
|
||||
{
|
||||
_chatService.TopicResult = (null, true);
|
||||
_channelService.TopicResult = (null, true);
|
||||
|
||||
var lines = await RunAuthenticated(["JOIN #general"]);
|
||||
|
||||
@@ -439,7 +440,7 @@ public class IrcCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task Topic_Query_ReturnsTopic()
|
||||
{
|
||||
_chatService.TopicResult = ("Chat about everything", true);
|
||||
_channelService.TopicResult = ("Chat about everything", true);
|
||||
|
||||
var lines = await RunAuthenticated(["TOPIC #general"]);
|
||||
|
||||
@@ -543,7 +544,7 @@ public class IrcCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task List_ReturnsChannels()
|
||||
{
|
||||
_chatService.ChannelListToReturn =
|
||||
_channelService.ChannelListToReturn =
|
||||
[
|
||||
new("general", "General chat", 5),
|
||||
new("random", null, 2),
|
||||
|
||||
@@ -158,8 +158,6 @@ internal sealed class FakeChatService : IChatService
|
||||
public string? SendMessageError { get; set; }
|
||||
public (Guid UserId, string Username)? AuthResult { 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<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
|
||||
|
||||
@@ -215,15 +213,48 @@ internal sealed class FakeChatService : IChatService
|
||||
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
|
||||
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) =>
|
||||
Task.FromResult(ChannelsForUserToReturn);
|
||||
|
||||
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user