feat: Add invite codes and message replies functionality

- Introduced a new migration to add InviteCodes table and ReplyToMessageId column in Messages.
- Updated ChatHub to support replying to messages.
- Enhanced ChatService to handle message replies and validate reply targets.
- Modified UserService to implement invite-only registration mode with invite code consumption.
- Added configuration options for registration modes in appsettings.
- Created unit tests for new features including invite code registration and message reply formatting.
This commit is contained in:
HueByte
2026-07-17 19:47:57 +02:00
parent bb987dda82
commit 3281064720
44 changed files with 2484 additions and 74 deletions
@@ -0,0 +1,73 @@
using EchoHub.Core.Services;
using Xunit;
namespace EchoHub.Tests;
public class AsciiBannerServiceTests
{
[Fact]
public void Render_SimpleText_ProducesFiveRows()
{
var banner = AsciiBannerService.Render("hi");
Assert.NotNull(banner);
Assert.Equal(5, banner!.Split('\n').Length);
Assert.Contains("█", banner);
}
[Fact]
public void Render_IsCaseInsensitive()
{
Assert.Equal(AsciiBannerService.Render("abc"), AsciiBannerService.Render("ABC"));
}
[Fact]
public void Render_Empty_ReturnsNull()
{
Assert.Null(AsciiBannerService.Render(""));
Assert.Null(AsciiBannerService.Render(" "));
}
[Fact]
public void Render_OnlyUnsupportedChars_ReturnsNull()
{
Assert.Null(AsciiBannerService.Render("🦆🦆🦆"));
}
[Fact]
public void Render_UnsupportedCharsSkipped_SupportedRemain()
{
var mixed = AsciiBannerService.Render("a🦆b");
var plain = AsciiBannerService.Render("ab");
Assert.Equal(plain, mixed);
}
[Fact]
public void Render_InputLongerThanCap_IsTruncatedNotRejected()
{
var banner = AsciiBannerService.Render(new string('a', AsciiBannerService.MaxInputLength + 30));
Assert.NotNull(banner);
// 20 glyphs of 'A' (4 cols) + 19 separators — sane width, not 50 glyphs
var firstRow = banner!.Split('\n')[0];
Assert.True(firstRow.Length <= AsciiBannerService.MaxInputLength * 6);
}
[Fact]
public void Render_DigitsAndPunctuation_Supported()
{
Assert.NotNull(AsciiBannerService.Render("42!"));
Assert.NotNull(AsciiBannerService.Render("v0.2"));
}
[Fact]
public void Render_FitsMessageLimits()
{
// Worst case must stay under the server's message length cap
var banner = AsciiBannerService.Render(new string('w', AsciiBannerService.MaxInputLength));
Assert.NotNull(banner);
Assert.True(banner!.Length <= EchoHub.Core.Constants.HubConstants.MaxMessageLength);
}
}
+156 -3
View File
@@ -83,17 +83,48 @@ public class CommandHandlerTests
}
[Fact]
public async Task HandleAsync_StatusCustomMessage_SetsStatusMessage()
public async Task HandleAsync_StatusMsg_SetsStatusMessageAndKeepsStatus()
{
var handler = CreateHandler();
UserStatus? capturedStatus = UserStatus.Online;
string? capturedMessage = null;
handler.OnSetStatus += (status, msg) => { capturedMessage = msg; return Task.CompletedTask; };
handler.OnSetStatus += (status, msg) => { capturedStatus = status; capturedMessage = msg; return Task.CompletedTask; };
var result = await handler.HandleAsync("/status brb lunch");
var result = await handler.HandleAsync("/status msg brb lunch");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Contains("brb lunch", result.Message);
Assert.Equal("brb lunch", capturedMessage);
Assert.Null(capturedStatus); // null = keep the current status
}
[Fact]
public async Task HandleAsync_StatusMsgNoText_ClearsMessage()
{
var handler = CreateHandler();
string? capturedMessage = "sentinel";
handler.OnSetStatus += (status, msg) => { capturedMessage = msg; return Task.CompletedTask; };
var result = await handler.HandleAsync("/status msg");
Assert.False(result.IsError);
Assert.Contains("cleared", result.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(string.Empty, capturedMessage); // empty = clear
}
[Fact]
public async Task HandleAsync_StatusUnknownValue_IsErrorAndDoesNotFire()
{
var handler = CreateHandler();
var fired = false;
handler.OnSetStatus += (_, _) => { fired = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/status garbage");
Assert.True(result.IsError);
Assert.Contains("Unknown status", result.Message);
Assert.False(fired);
}
[Fact]
@@ -106,6 +137,128 @@ public class CommandHandlerTests
Assert.Contains("Usage", result.Message);
}
// ── /me ───────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Me_FiresActionEvent()
{
var handler = CreateHandler();
string? captured = null;
handler.OnSendAction += text => { captured = text; return Task.CompletedTask; };
var result = await handler.HandleAsync("/me waves at everyone");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Equal("waves at everyone", captured);
}
[Fact]
public async Task HandleAsync_MeNoArgs_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/me");
Assert.True(result.IsError);
Assert.Contains("Usage", result.Message);
}
// ── /banner ───────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Banner_FiresBannerEvent()
{
var handler = CreateHandler();
string? captured = null;
handler.OnSendBanner += text => { captured = text; return Task.CompletedTask; };
var result = await handler.HandleAsync("/banner hi");
Assert.True(result.Handled);
Assert.Equal("hi", captured);
}
// ── /invite ───────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Invite_DefaultsFireCreateWithNulls()
{
var handler = CreateHandler();
(int? Uses, int? Hours)? captured = null;
handler.OnCreateInvite += (uses, hours) => { captured = (uses, hours); return Task.CompletedTask; };
var result = await handler.HandleAsync("/invite");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Equal((null, null), captured);
}
[Fact]
public async Task HandleAsync_InviteWithArgs_ParsesUsesAndHours()
{
var handler = CreateHandler();
(int? Uses, int? Hours)? captured = null;
handler.OnCreateInvite += (uses, hours) => { captured = (uses, hours); return Task.CompletedTask; };
await handler.HandleAsync("/invite 5 48");
Assert.Equal((5, 48), captured);
}
[Fact]
public async Task HandleAsync_InviteRevoke_FiresRevokeWithCode()
{
var handler = CreateHandler();
string? captured = null;
handler.OnRevokeInvite += code => { captured = code; return Task.CompletedTask; };
await handler.HandleAsync("/invite revoke K7QM-3XPF");
Assert.Equal("K7QM-3XPF", captured);
}
[Fact]
public async Task HandleAsync_InviteBadArgs_ReturnsError()
{
var handler = CreateHandler();
var fired = false;
handler.OnCreateInvite += (_, _) => { fired = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/invite zero");
Assert.True(result.IsError);
Assert.False(fired);
}
// ── /export, /deleteaccount ───────────────────────────────────────
[Fact]
public async Task HandleAsync_Export_FiresExportEvent()
{
var handler = CreateHandler();
var fired = false;
handler.OnExportData += () => { fired = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/export");
Assert.True(result.Handled);
Assert.True(fired);
}
[Fact]
public async Task HandleAsync_DeleteAccount_FiresDeleteEvent()
{
var handler = CreateHandler();
var fired = false;
handler.OnDeleteAccount += () => { fired = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/deleteaccount");
Assert.True(result.Handled);
Assert.True(fired);
}
// ── /nick ─────────────────────────────────────────────────────────
[Fact]
+8 -3
View File
@@ -188,7 +188,7 @@ internal sealed class FakeChatService : IChatService
return Task.CompletedTask;
}
public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null)
public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
{
SentMessages.Add((channelName, content));
return Task.FromResult(SendMessageError);
@@ -305,9 +305,14 @@ internal sealed class FakeUserService : IUserService
Task.FromResult(AuthResult
?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password."));
public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null) =>
Task.FromResult(RegisterResult
public List<string?> RegisterInviteCodes { get; } = [];
public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null, string? inviteCode = null)
{
RegisterInviteCodes.Add(inviteCode);
return Task.FromResult(RegisterResult
?? UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken."));
}
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
+77 -2
View File
@@ -12,7 +12,8 @@ public class IrcMessageFormatterTests
string sender = "alice",
string channel = "general",
List<AttachmentDto>? attachments = null,
List<EmbedDto>? embeds = null) => new(
List<EmbedDto>? embeds = null,
ReplyRefDto? replyTo = null) => new(
Id: Guid.NewGuid(),
Content: content,
SenderUsername: sender,
@@ -20,7 +21,81 @@ public class IrcMessageFormatterTests
ChannelName: channel,
SentAt: DateTimeOffset.UtcNow,
Attachments: attachments,
Embeds: embeds);
Embeds: embeds,
ReplyTo: replyTo);
// ── CTCP ACTION (/me) ───────────────────────────────────
[Fact]
public void FormatMessage_ActionContent_EmitsCtcpAction()
{
var msg = CreateMessage(content: "\u0001ACTION waves at everyone\u0001");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("PRIVMSG #general :\u0001ACTION waves at everyone\u0001", lines[0]);
}
[Fact]
public void FormatMessage_LongActionContent_EachChunkIsWellFormedCtcp()
{
var longText = string.Join(' ', Enumerable.Repeat("wordyword", 80));
var msg = CreateMessage(content: "\u0001ACTION " + longText + "\u0001");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.True(lines.Count > 1);
foreach (var line in lines)
{
var payload = line[(line.IndexOf(" :", StringComparison.Ordinal) + 2)..];
Assert.StartsWith("\u0001ACTION ", payload);
Assert.EndsWith("\u0001", payload);
}
}
// ── Replies ───────────────────────────────────────────
[Fact]
public void FormatMessage_Reply_PrefixesQuoteConvention()
{
var reply = new ReplyRefDto(Guid.NewGuid(), "bob", "the original text");
var msg = CreateMessage(content: "I agree", replyTo: reply);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("PRIVMSG #general :> bob: the original text | I agree", lines[0]);
}
[Fact]
public void FormatMessage_ReplyToLongMessage_SnippetTruncated()
{
var reply = new ReplyRefDto(Guid.NewGuid(), "bob", new string('x', 300));
var msg = CreateMessage(content: "ok", replyTo: reply);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("… | ok", lines[0]);
Assert.DoesNotContain(new string('x', 100), lines[0]);
}
[Fact]
public void FormatMessage_ReplyToEncryptedContent_ShowsPlaceholder()
{
var reply = new ReplyRefDto(Guid.NewGuid(), "bob", "$RC1$AAAA$BBBB$CCCC");
var msg = CreateMessage(content: "ok", replyTo: reply);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Contains("> bob: [encrypted] | ok", lines[0]);
}
[Fact]
public void FormatMessage_ReplyToAction_SnippetRendersAsAction()
{
var reply = new ReplyRefDto(Guid.NewGuid(), "bob", "\u0001ACTION waves\u0001");
var msg = CreateMessage(content: "nice wave", replyTo: reply);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Contains("> bob: * bob waves | nice wave", lines[0]);
}
// ── FormatMessage ─────────────────────────────────────────────────
@@ -0,0 +1,44 @@
using EchoHub.Core.Constants;
using Xunit;
namespace EchoHub.Tests;
public class MessageConventionsTests
{
[Fact]
public void FormatAction_RoundTripsThroughTryParse()
{
var wire = MessageConventions.FormatAction("waves at everyone");
Assert.True(MessageConventions.TryParseAction(wire, out var text));
Assert.Equal("waves at everyone", text);
}
[Fact]
public void FormatAction_UsesCtcpDelimiters()
{
var wire = MessageConventions.FormatAction("waves");
// Exact IRC CTCP ACTION wire shape: \x01ACTION waves\x01
Assert.Equal("\u0001ACTION waves\u0001", wire);
}
[Fact]
public void TryParseAction_PlainText_ReturnsFalse()
{
Assert.False(MessageConventions.TryParseAction("hello world", out _));
}
[Fact]
public void TryParseAction_TextStartingWithWordAction_ReturnsFalse()
{
// A user typing "ACTION stations!" is not a /me
Assert.False(MessageConventions.TryParseAction("ACTION stations!", out _));
}
[Fact]
public void TryParseAction_EmptyAction_ReturnsFalse()
{
Assert.False(MessageConventions.TryParseAction("\u0001ACTION \u0001", out _));
}
}
@@ -0,0 +1,242 @@
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// Registration gate tests: open/invite/closed modes, invite-code consumption,
/// first-user bootstrap, and the reserved tombstone username. Runs against a real
/// SQLite in-memory database because the gate includes a guarded UPDATE.
/// </summary>
public sealed class UserServiceRegistrationTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
public UserServiceRegistrationTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var services = new ServiceCollection();
services.AddDbContext<EchoHubDbContext>(o => o.UseSqlite(_connection));
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
scope.ServiceProvider.GetRequiredService<EchoHubDbContext>().Database.EnsureCreated();
}
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
private UserService CreateService(string? registrationMode = null)
{
var settings = new Dictionary<string, string?>();
if (registrationMode is not null)
settings["Server:Registration"] = registrationMode;
var config = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
return new UserService(_provider.GetRequiredService<IServiceScopeFactory>(), config);
}
private EchoHubDbContext Db()
{
// Root-scope context is fine here: the connection is shared, EnsureCreated ran
return _provider.GetRequiredService<IServiceScopeFactory>()
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
}
private async Task<InviteCode> SeedInviteAsync(int maxUses = 1, DateTimeOffset? expiresAt = null, int useCount = 0)
{
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var invite = new InviteCode
{
Id = Guid.NewGuid(),
Code = "TEST-CODE",
CreatedByUserId = Guid.NewGuid(),
CreatedByUsername = "admin",
ExpiresAt = expiresAt,
MaxUses = maxUses,
UseCount = useCount,
};
db.InviteCodes.Add(invite);
await db.SaveChangesAsync();
return invite;
}
private async Task SeedOwnerAsync()
{
// First registration is always allowed and becomes Owner
var service = CreateService("open");
var result = await service.RegisterUserAsync("owner", "password123");
Assert.True(result.IsSuccess);
}
// ── Open mode ─────────────────────────────────────────────────────
[Fact]
public async Task OpenMode_RegistersWithoutCode()
{
await SeedOwnerAsync();
var service = CreateService("open");
var result = await service.RegisterUserAsync("alice", "password123");
Assert.True(result.IsSuccess);
Assert.Equal(ServerRole.Member, result.User!.Role);
}
[Fact]
public async Task DefaultMode_IsOpen()
{
await SeedOwnerAsync();
var service = CreateService(registrationMode: null);
var result = await service.RegisterUserAsync("alice", "password123");
Assert.True(result.IsSuccess);
}
// ── Closed mode ───────────────────────────────────────────────────
[Fact]
public async Task ClosedMode_RefusesRegistration()
{
await SeedOwnerAsync();
var service = CreateService("closed");
var result = await service.RegisterUserAsync("alice", "password123");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.ValidationFailed, result.Error);
Assert.Contains("closed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ClosedMode_FirstUserBootstrap_StillAllowed()
{
var service = CreateService("closed");
var result = await service.RegisterUserAsync("owner", "password123");
Assert.True(result.IsSuccess);
Assert.Equal(ServerRole.Owner, result.User!.Role);
}
// ── Invite mode ───────────────────────────────────────────────────
[Fact]
public async Task InviteMode_NoCode_Refused()
{
await SeedOwnerAsync();
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123");
Assert.False(result.IsSuccess);
Assert.Contains("invite", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task InviteMode_ValidCode_RegistersAndConsumesUse()
{
await SeedOwnerAsync();
await SeedInviteAsync(maxUses: 2);
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123", inviteCode: "test-code");
Assert.True(result.IsSuccess);
using var db = Db();
Assert.Equal(1, (await db.InviteCodes.SingleAsync()).UseCount);
}
[Fact]
public async Task InviteMode_ExhaustedCode_Refused()
{
await SeedOwnerAsync();
await SeedInviteAsync(maxUses: 1, useCount: 1);
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123", inviteCode: "TEST-CODE");
Assert.False(result.IsSuccess);
Assert.Contains("Invalid invite", result.ErrorMessage);
}
[Fact]
public async Task InviteMode_ExpiredCode_Refused()
{
await SeedOwnerAsync();
await SeedInviteAsync(expiresAt: DateTimeOffset.UtcNow.AddHours(-1));
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123", inviteCode: "TEST-CODE");
Assert.False(result.IsSuccess);
Assert.Contains("expired", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task InviteMode_WrongCode_Refused()
{
await SeedOwnerAsync();
await SeedInviteAsync();
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123", inviteCode: "WRONG-ONE");
Assert.False(result.IsSuccess);
}
[Fact]
public async Task InviteMode_UsernameTaken_DoesNotConsumeCode()
{
await SeedOwnerAsync();
await SeedInviteAsync(maxUses: 1);
var service = CreateService("invite");
var result = await service.RegisterUserAsync("owner", "password123", inviteCode: "TEST-CODE");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.AlreadyExists, result.Error);
using var db = Db();
Assert.Equal(0, (await db.InviteCodes.SingleAsync()).UseCount);
}
[Fact]
public async Task InviteMode_FirstUserBootstrap_NeedsNoCode()
{
var service = CreateService("invite");
var result = await service.RegisterUserAsync("owner", "password123");
Assert.True(result.IsSuccess);
Assert.Equal(ServerRole.Owner, result.User!.Role);
}
// ── Reserved username ─────────────────────────────────────────────
[Fact]
public async Task ReservedTombstoneUsername_Refused()
{
await SeedOwnerAsync();
var service = CreateService("open");
var result = await service.RegisterUserAsync("deleted-user", "password123");
Assert.False(result.IsSuccess);
Assert.Contains("reserved", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
}