using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Config;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using EchoHub.Server.Services.ServerLogs;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace EchoHub.Tests;
///
/// System-channel behavior of (the live server-log room):
/// name reservation, role-gated visibility + top ordering, delete protection, and the
/// join-time role gate with auto-recreation. Runs against a real SQLite in-memory database.
///
public sealed class ChannelServiceSystemChannelTests : IDisposable
{
private const string LogRoom = "server-logs";
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
private readonly ServerLogsService _serverLogs = new(new ServerLogsOptions
{
Enabled = true,
RoomName = LogRoom,
MinRole = ServerRole.Mod,
});
public ChannelServiceSystemChannelTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var services = new ServiceCollection();
services.AddDbContext(o => o.UseSqlite(_connection));
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
scope.ServiceProvider.GetRequiredService().Database.EnsureCreated();
}
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
private ChannelService CreateService() => new(
_provider.GetRequiredService(),
new PresenceTracker(),
// Disable the spam guard so channel-create throttling never interferes with assertions.
new SpamGuard(new SpamOptions { Enabled = false }),
_serverLogs,
NullLogger.Instance);
private async Task SeedUserAsync(ServerRole role)
{
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService();
var user = new User
{
Id = Guid.NewGuid(),
Username = "user-" + Guid.NewGuid().ToString("N")[..8],
PasswordHash = "x",
Role = role,
};
db.Users.Add(user);
await db.SaveChangesAsync();
return user.Id;
}
private EchoHubDbContext Db() =>
_provider.GetRequiredService()
.CreateScope().ServiceProvider.GetRequiredService();
// ── Name reservation ──────────────────────────────────────────────
[Fact]
public async Task CreateChannel_WithReservedLogRoomName_IsRejected()
{
var service = CreateService();
var userId = await SeedUserAsync(ServerRole.Owner);
var result = await service.CreateChannelAsync(userId, LogRoom, null, isPublic: true);
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.ValidationFailed, result.Error);
}
// ── EnsureSystemChannelAsync ──────────────────────────────────────
[Fact]
public async Task EnsureSystemChannel_CreatesPrivateSystemChannel()
{
var service = CreateService();
var dto = await service.EnsureSystemChannelAsync(LogRoom, "Live server logs");
Assert.True(dto.IsSystem);
Assert.False(dto.IsPublic);
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
Assert.True(stored.IsSystem);
Assert.False(stored.IsPublic);
}
[Fact]
public async Task EnsureSystemChannel_IsIdempotent()
{
var service = CreateService();
var first = await service.EnsureSystemChannelAsync(LogRoom);
var second = await service.EnsureSystemChannelAsync(LogRoom);
Assert.Equal(first.Id, second.Id);
Assert.Equal(1, await Db().Channels.CountAsync(c => c.Name == LogRoom));
}
[Fact]
public async Task EnsureSystemChannel_ClaimsExistingRegularChannelOfSameName()
{
// A regular channel squatting on the name (created while the feature was off) must be
// reclaimed so server content never streams into a user-owned room.
using (var scope = _provider.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService();
db.Channels.Add(new Channel
{
Id = Guid.NewGuid(),
Name = LogRoom,
IsPublic = true,
IsSystem = false,
PasswordHash = "hash",
CreatedByUserId = Guid.NewGuid(),
});
await db.SaveChangesAsync();
}
var service = CreateService();
var dto = await service.EnsureSystemChannelAsync(LogRoom);
Assert.True(dto.IsSystem);
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
Assert.True(stored.IsSystem);
Assert.False(stored.IsPublic);
Assert.Null(stored.PasswordHash);
}
// ── Visibility + ordering ─────────────────────────────────────────
[Fact]
public async Task GetChannels_HidesSystemChannelFromMembers()
{
var service = CreateService();
await service.EnsureSystemChannelAsync(LogRoom);
var memberId = await SeedUserAsync(ServerRole.Member);
var page = await service.GetChannelsAsync(memberId, 0, 50);
Assert.DoesNotContain(page.Items, c => c.Name == LogRoom);
}
[Fact]
public async Task GetChannels_ShowsSystemChannelToMods_PinnedAtTop()
{
var service = CreateService();
await service.EnsureSystemChannelAsync(LogRoom);
var modId = await SeedUserAsync(ServerRole.Mod);
var page = await service.GetChannelsAsync(modId, 0, 50);
var logRoom = Assert.Single(page.Items, c => c.Name == LogRoom);
Assert.True(logRoom.IsSystem);
// Sorts above 'general' (auto-created, alphabetically after 's' would normally lose).
Assert.Equal(LogRoom, page.Items[0].Name);
}
// ── Delete protection ─────────────────────────────────────────────
[Fact]
public async Task DeleteChannel_SystemChannel_IsRefused()
{
var service = CreateService();
await service.EnsureSystemChannelAsync(LogRoom);
var ownerId = await SeedUserAsync(ServerRole.Owner);
var result = await service.DeleteChannelAsync(ownerId, LogRoom);
Assert.False(result.IsSuccess);
Assert.Equal(ChannelError.Protected, result.Error);
Assert.True(await Db().Channels.AnyAsync(c => c.Name == LogRoom));
}
// ── Join-time role gate + auto-recreate ───────────────────────────
[Fact]
public async Task EnsureMembership_LogRoom_RejectsMember()
{
var service = CreateService();
await service.EnsureSystemChannelAsync(LogRoom);
var memberId = await SeedUserAsync(ServerRole.Member);
var (success, error, _) = await service.EnsureChannelMembershipAsync(memberId, LogRoom);
Assert.False(success);
Assert.NotNull(error);
}
[Fact]
public async Task EnsureMembership_LogRoom_AllowsModAndRecreatesIfMissing()
{
// No prior EnsureSystemChannelAsync — a Mod joining a missing log room recreates it.
var service = CreateService();
var modId = await SeedUserAsync(ServerRole.Mod);
var (success, error, _) = await service.EnsureChannelMembershipAsync(modId, LogRoom);
Assert.True(success);
Assert.Null(error);
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
Assert.True(stored.IsSystem);
}
}