mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user