using System.Net.Sockets; using System.Text; using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using EchoHub.Server.Irc; namespace EchoHub.Tests.Irc; /// /// A duplex stream that reads from one buffer and writes to another, /// allowing test code to inject input and capture output. /// internal sealed class TestDuplexStream : Stream { private readonly MemoryStream _readBuffer; private readonly MemoryStream _writeBuffer = new(); public TestDuplexStream(string input = "") { _readBuffer = new MemoryStream(Encoding.UTF8.GetBytes(input)); } public string GetOutput() { var raw = Encoding.UTF8.GetString(_writeBuffer.ToArray()); // Strip UTF-8 BOM emitted by StreamWriter return raw.TrimStart('\uFEFF'); } public List GetOutputLines() => GetOutput().Split("\r\n", StringSplitOptions.RemoveEmptyEntries).ToList(); // Read from the input buffer public override int Read(byte[] buffer, int offset, int count) => _readBuffer.Read(buffer, offset, count); public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => _readBuffer.ReadAsync(buffer, offset, count, ct); public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => _readBuffer.ReadAsync(buffer, ct); // Write to the output buffer public override void Write(byte[] buffer, int offset, int count) => _writeBuffer.Write(buffer, offset, count); public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => _writeBuffer.WriteAsync(buffer, offset, count, ct); public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) => _writeBuffer.WriteAsync(buffer, ct); public override void Flush() => _writeBuffer.Flush(); public override Task FlushAsync(CancellationToken ct) => _writeBuffer.FlushAsync(ct); public override bool CanRead => true; public override bool CanWrite => true; public override bool CanSeek => false; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); protected override void Dispose(bool disposing) { if (disposing) { _readBuffer.Dispose(); _writeBuffer.Dispose(); } base.Dispose(disposing); } } /// /// Creates IrcClientConnections backed by test streams for unit testing. /// internal static class TestIrcConnectionFactory { /// /// Creates a test IRC connection with the given input lines. /// Returns the connection and the test stream (for inspecting output). /// public static (IrcClientConnection Connection, TestDuplexStream Stream) Create(params string[] inputLines) { var input = string.Join("\r\n", inputLines); if (inputLines.Length > 0) input += "\r\n"; var stream = new TestDuplexStream(input); var tcpClient = new TcpClient(); var conn = new IrcClientConnection(tcpClient, stream); return (conn, stream); } /// /// Creates a pre-authenticated, registered IRC connection. /// public static (IrcClientConnection Connection, TestDuplexStream Stream) CreateAuthenticated( string nickname = "alice", Guid? userId = null, params string[] inputLines) { var (conn, stream) = Create(inputLines); conn.Nickname = nickname; conn.Username = nickname; conn.UserId = userId ?? Guid.NewGuid(); conn.IsRegistered = true; conn.IsAuthenticated = true; return (conn, stream); } } /// /// Fake encryption service that uses a simple reversible prefix-based scheme. /// Encrypt("hello") → "$ENC$hello", Decrypt("$ENC$hello") → "hello". /// internal sealed class FakeEncryptionService : IMessageEncryptionService { private const string Prefix = "$ENC$"; public bool EncryptDatabaseEnabled => true; public string Encrypt(string plaintext) => $"{Prefix}{plaintext}"; public string Decrypt(string content) { if (content.StartsWith(Prefix)) return content[Prefix.Length..]; return content; } public string? EncryptNullable(string? value) => value is not null ? Encrypt(value) : null; public string? DecryptNullable(string? value) => value is not null ? Decrypt(value) : null; } /// /// Fake chat service that records method calls and returns pre-configured results. /// internal sealed class FakeChatService : IChatService { // Recorded calls public List ConnectedUsers { get; } = []; public List DisconnectedConnections { get; } = []; public List<(string Channel, string Username)> JoinedChannels { get; } = []; public List<(string Channel, string Username)> LeftChannels { get; } = []; public List<(string Channel, string Content)> SentMessages { get; } = []; public List<(string Username, UserStatus Status)> StatusUpdates { get; } = []; // Configurable results public List HistoryToReturn { get; set; } = []; public string? JoinError { get; set; } public bool JoinPasswordRequired { get; set; } public string? SendMessageError { get; set; } public List ChannelsForUserToReturn { get; set; } = []; public List OnlineUsersToReturn { get; set; } = []; public Task UserConnectedAsync(string connectionId, Guid userId, string username) { ConnectedUsers.Add(username); return Task.CompletedTask; } public Task UserDisconnectedAsync(string connectionId) { DisconnectedConnections.Add(connectionId); return Task.FromResult(null); } public List JoinKeys { get; } = []; public Task<(List History, string? Error, bool PasswordRequired)> JoinChannelAsync( string connectionId, Guid userId, string username, string channelName, string? password = null) { JoinedChannels.Add((channelName, username)); JoinKeys.Add(password); return Task.FromResult((HistoryToReturn, JoinError, JoinPasswordRequired)); } public Task LeaveChannelAsync(string connectionId, string username, string channelName) { LeftChannels.Add((channelName, username)); return Task.CompletedTask; } public Task SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null) { SentMessages.Add((channelName, content)); return Task.FromResult(SendMessageError); } public Task> GetChannelHistoryAsync(string channelName, int count, int offset = 0) => Task.FromResult(HistoryToReturn); public Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) { StatusUpdates.Add((username, status)); return Task.FromResult(null); } public Task> GetOnlineUsersAsync(string channelName) => Task.FromResult(OnlineUsersToReturn); public Task BroadcastMessageAsync(string channelName, MessageDto message) => Task.CompletedTask; public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) => Task.CompletedTask; public Task BroadcastChannelDeletedAsync(string channelName) => Task.CompletedTask; public Task> GetChannelsForUserAsync(string username) => Task.FromResult(ChannelsForUserToReturn); } /// /// Fake channel service that records method calls and returns pre-configured results. /// internal sealed class FakeChannelService : IChannelService { // Configurable results public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true); public List ChannelListToReturn { get; set; } = []; public ChannelDto? ChannelByNameToReturn { get; set; } public ChannelOperationResult? CreateResult { get; set; } public ChannelOperationResult? UpdateTopicResult { get; set; } public ChannelOperationResult? DeleteResult { get; set; } public ChannelOperationResult? SetPasswordResult { get; set; } public (bool Success, string? Error, bool PasswordRequired) MembershipResult { get; set; } = (true, null, false); public Task> GetChannelsAsync(Guid userId, int offset, int limit) => Task.FromResult(new PaginatedResponse([], 0, offset, limit)); public ChannelCryptoDto? CryptoToReturn { get; set; } public ChannelOperationResult? RekeyResult { get; set; } public (string? EncryptionSalt, string? WrappedRoomKey) KeyEnvelopeToReturn { get; set; } public Task CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic, string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null) => Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); public Task GetChannelCryptoAsync(string channelName) => Task.FromResult(CryptoToReturn); public Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName) => Task.FromResult(KeyEnvelopeToReturn); public Task RekeyChannelAsync(Guid callerUserId, string channelName, string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey) => Task.FromResult(RekeyResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); public Task UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) => Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); public Task SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password) => Task.FromResult(SetPasswordResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured")); public Task 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> GetChannelListAsync() => Task.FromResult(ChannelListToReturn); public Task GetChannelByNameAsync(string channelName) => Task.FromResult(ChannelByNameToReturn); public ChannelMetaDto? ChannelMetaToReturn { get; set; } public Task GetChannelMetaAsync(string channelName) => Task.FromResult(ChannelMetaToReturn); public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) => Task.FromResult(MembershipResult); public ChannelDto? SystemChannelToReturn { get; set; } public Task EnsureSystemChannelAsync(string channelName, string? topic = null) => Task.FromResult(SystemChannelToReturn ?? new ChannelDto( Guid.NewGuid(), channelName, topic, false, 0, DateTimeOffset.UnixEpoch, false, false, true)); } /// /// Fake user service that records method calls and returns pre-configured results. /// internal sealed class FakeUserService : IUserService { // Configurable results public UserOperationResult? AuthResult { get; set; } public UserOperationResult? RegisterResult { get; set; } public UserProfileDto? ProfileToReturn { get; set; } /// /// Helper to create a success result from a simple userId + username pair. /// public static UserOperationResult SuccessResult(Guid userId, string username) => UserOperationResult.Success(new UserProfileDto( userId, username, null, null, null, null, UserStatus.Online, null, ServerRole.Member, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); public Task AuthenticateUserAsync(string username, string password) => Task.FromResult(AuthResult ?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password.")); public List RegisterInviteCodes { get; } = []; public Task 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 GetUserProfileAsync(string username) => Task.FromResult(ProfileToReturn); public Task GetUserByIdAsync(Guid userId) => Task.FromResult(ProfileToReturn); public Task UpdateProfileAsync(Guid userId, string? displayName, string? bio, string? nicknameColor) => Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured")); public Task SetAvatarAsync(Guid userId, string asciiArt) => Task.FromResult(UserOperationResult.Fail(UserError.NotFound, "Not configured")); }