diff --git a/src/EchoHub.Tests/ChannelServiceTests.cs b/src/EchoHub.Tests/ChannelServiceTests.cs new file mode 100644 index 0000000..e82c091 --- /dev/null +++ b/src/EchoHub.Tests/ChannelServiceTests.cs @@ -0,0 +1,441 @@ +using EchoHub.Core.Constants; +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; + +/// +/// General CRUD, validation, password gates, and role/creator +/// authorization. (System-channel behavior lives in .) +/// Runs against a real SQLite in-memory database so the guarded queries and FK relationships +/// behave as in production. +/// +public sealed class ChannelServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ServiceProvider _provider; + // Default options → reserved name "server-logs"; feature enabled but never targeted here. + private readonly ServerLogsService _serverLogs = new(new ServerLogsOptions()); + + public ChannelServiceTests() + { + _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(), + new SpamGuard(new SpamOptions { Enabled = false }), + _serverLogs, + NullLogger.Instance); + + private async Task SeedUserAsync(ServerRole role = ServerRole.Member) + { + 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(); + + // ── Create: happy path + membership ─────────────────────────────── + + [Fact] + public async Task CreateChannel_Valid_SucceedsAndAddsCreatorMembership() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + + var result = await service.CreateChannelAsync(creator, "dev-talk", "About dev", isPublic: true); + + Assert.True(result.IsSuccess); + Assert.Equal("dev-talk", result.Channel!.Name); + Assert.True(result.Channel.IsPublic); + + var channel = await Db().Channels.SingleAsync(c => c.Name == "dev-talk"); + Assert.True(await Db().ChannelMemberships.AnyAsync(m => m.ChannelId == channel.Id && m.UserId == creator)); + } + + [Fact] + public async Task CreateChannel_LowercasesName() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + + var result = await service.CreateChannelAsync(creator, "DevTalk", null, isPublic: true); + + Assert.True(result.IsSuccess); + Assert.Equal("devtalk", result.Channel!.Name); + } + + // ── Create: validation ──────────────────────────────────────────── + + [Theory] + [InlineData("a")] // too short (< 2) + [InlineData("has space")] // invalid character + [InlineData("bang!")] // invalid character + public async Task CreateChannel_InvalidName_Rejected(string name) + { + var service = CreateService(); + var creator = await SeedUserAsync(); + + var result = await service.CreateChannelAsync(creator, name, null, isPublic: true); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.ValidationFailed, result.Error); + } + + [Fact] + public async Task CreateChannel_DuplicateName_Rejected() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + await service.CreateChannelAsync(creator, "dupe", null, isPublic: true); + + var result = await service.CreateChannelAsync(creator, "dupe", null, isPublic: true); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.AlreadyExists, result.Error); + } + + [Fact] + public async Task CreateChannel_ShortPassword_Rejected() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + + var result = await service.CreateChannelAsync(creator, "locked", null, isPublic: true, password: "ab"); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.ValidationFailed, result.Error); + } + + [Fact] + public async Task CreateChannel_WithPassword_IsMarkedProtectedAndHashed() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + + var result = await service.CreateChannelAsync(creator, "locked", null, isPublic: true, password: "secret"); + + Assert.True(result.IsSuccess); + Assert.True(result.Channel!.IsProtected); + var stored = await Db().Channels.SingleAsync(c => c.Name == "locked"); + Assert.NotNull(stored.PasswordHash); + Assert.NotEqual("secret", stored.PasswordHash); // hashed, not plaintext + } + + [Fact] + public async Task CreateChannel_EncryptionEnvelopeWithoutPassword_Rejected() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + + var result = await service.CreateChannelAsync(creator, "e2e", null, isPublic: false, + password: null, encryptionSalt: "salt", wrappedRoomKey: "wrapped"); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.ValidationFailed, result.Error); + } + + [Fact] + public async Task CreateChannel_EncryptedChannel_ExposesCryptoMetadataButNotKey() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + await service.CreateChannelAsync(creator, "e2e", null, isPublic: false, + password: "passphrase", encryptionSalt: "the-salt", wrappedRoomKey: "the-wrapped-key"); + + var crypto = await service.GetChannelCryptoAsync("e2e"); + var (salt, wrapped) = await service.GetChannelKeyEnvelopeAsync("e2e"); + + Assert.True(crypto!.IsEncrypted); + Assert.Equal("the-salt", crypto.EncryptionSalt); + Assert.Equal("the-salt", salt); + Assert.Equal("the-wrapped-key", wrapped); + } + + // ── Visibility ──────────────────────────────────────────────────── + + [Fact] + public async Task GetChannels_ShowsPublicAndOwnPrivate_HidesOthersPrivate() + { + var service = CreateService(); + var owner = await SeedUserAsync(); + var outsider = await SeedUserAsync(); + await service.CreateChannelAsync(owner, "public-room", null, isPublic: true); + await service.CreateChannelAsync(owner, "private-room", null, isPublic: false); + + var outsiderView = await service.GetChannelsAsync(outsider, 0, 50); + + Assert.Contains(outsiderView.Items, c => c.Name == "public-room"); + Assert.DoesNotContain(outsiderView.Items, c => c.Name == "private-room"); + + var ownerView = await service.GetChannelsAsync(owner, 0, 50); + Assert.Contains(ownerView.Items, c => c.Name == "private-room"); + } + + // ── Membership + password gate ──────────────────────────────────── + + [Fact] + public async Task EnsureMembership_ProtectedChannel_RequiresCorrectPassword() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + await service.CreateChannelAsync(creator, "vault", null, isPublic: true, password: "opensesame"); + var joiner = await SeedUserAsync(); + + var noPassword = await service.EnsureChannelMembershipAsync(joiner, "vault"); + Assert.False(noPassword.Success); + Assert.True(noPassword.PasswordRequired); + + var wrongPassword = await service.EnsureChannelMembershipAsync(joiner, "vault", "nope"); + Assert.False(wrongPassword.Success); + Assert.True(wrongPassword.PasswordRequired); + + var correct = await service.EnsureChannelMembershipAsync(joiner, "vault", "opensesame"); + Assert.True(correct.Success); + } + + [Fact] + public async Task EnsureMembership_ExistingMember_NoPasswordNeeded() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + await service.CreateChannelAsync(creator, "vault", null, isPublic: true, password: "opensesame"); + + // Creator already has membership from creation → re-join needs no password. + var result = await service.EnsureChannelMembershipAsync(creator, "vault"); + + Assert.True(result.Success); + } + + [Fact] + public async Task EnsureMembership_NonexistentChannel_Fails() + { + var service = CreateService(); + var user = await SeedUserAsync(); + + var result = await service.EnsureChannelMembershipAsync(user, "ghost"); + + Assert.False(result.Success); + Assert.False(result.PasswordRequired); + } + + [Fact] + public async Task EnsureMembership_DefaultChannel_AutoRecreatedIfMissing() + { + var service = CreateService(); + var user = await SeedUserAsync(); + + var result = await service.EnsureChannelMembershipAsync(user, HubConstants.DefaultChannel); + + Assert.True(result.Success); + Assert.True(await Db().Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel)); + } + + // ── Topic ───────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateTopic_Creator_Succeeds() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + await service.CreateChannelAsync(creator, "room", null, isPublic: true); + + var result = await service.UpdateTopicAsync(creator, "room", "new topic"); + + Assert.True(result.IsSuccess); + Assert.Equal("new topic", result.Channel!.Topic); + } + + [Fact] + public async Task UpdateTopic_NonCreator_Forbidden() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + var other = await SeedUserAsync(ServerRole.Admin); // even an admin isn't the creator + await service.CreateChannelAsync(creator, "room", null, isPublic: true); + + var result = await service.UpdateTopicAsync(other, "room", "hijack"); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.Forbidden, result.Error); + } + + [Fact] + public async Task UpdateTopic_TooLong_Rejected() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + await service.CreateChannelAsync(creator, "room", null, isPublic: true); + + var result = await service.UpdateTopicAsync(creator, "room", + new string('x', ValidationConstants.MaxChannelTopicLength + 1)); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.ValidationFailed, result.Error); + } + + // ── Password management ─────────────────────────────────────────── + + [Fact] + public async Task SetChannelPassword_Admin_CanSetOnAnothersChannel() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + var admin = await SeedUserAsync(ServerRole.Admin); + await service.CreateChannelAsync(creator, "room", null, isPublic: true); + + var result = await service.SetChannelPasswordAsync(admin, "room", "newpass"); + + Assert.True(result.IsSuccess); + Assert.True(result.Channel!.IsProtected); + } + + [Fact] + public async Task SetChannelPassword_UnprivilegedNonCreator_Forbidden() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + var member = await SeedUserAsync(ServerRole.Member); + await service.CreateChannelAsync(creator, "room", null, isPublic: true); + + var result = await service.SetChannelPasswordAsync(member, "room", "newpass"); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.Forbidden, result.Error); + } + + [Fact] + public async Task SetChannelPassword_EncryptedChannel_Refused() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + await service.CreateChannelAsync(creator, "e2e", null, isPublic: false, + password: "passphrase", encryptionSalt: "salt", wrappedRoomKey: "wrapped"); + + var result = await service.SetChannelPasswordAsync(creator, "e2e", "newpass"); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.Protected, result.Error); + } + + // ── Delete ──────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteChannel_DefaultChannel_Protected() + { + var service = CreateService(); + var owner = await SeedUserAsync(ServerRole.Owner); + // GetChannels auto-creates #general. + await service.GetChannelsAsync(owner, 0, 50); + + var result = await service.DeleteChannelAsync(owner, HubConstants.DefaultChannel); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.Protected, result.Error); + } + + [Fact] + public async Task DeleteChannel_Creator_Succeeds() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + await service.CreateChannelAsync(creator, "temp", null, isPublic: true); + + var result = await service.DeleteChannelAsync(creator, "temp"); + + Assert.True(result.IsSuccess); + Assert.False(await Db().Channels.AnyAsync(c => c.Name == "temp")); + } + + [Fact] + public async Task DeleteChannel_UnprivilegedNonCreator_Forbidden() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + var member = await SeedUserAsync(ServerRole.Member); + await service.CreateChannelAsync(creator, "temp", null, isPublic: true); + + var result = await service.DeleteChannelAsync(member, "temp"); + + Assert.False(result.IsSuccess); + Assert.Equal(ChannelError.Forbidden, result.Error); + } + + // ── Queries ─────────────────────────────────────────────────────── + + [Fact] + public async Task GetChannelByName_UnknownChannel_ReturnsNull() + { + var service = CreateService(); + + Assert.Null(await service.GetChannelByNameAsync("nope")); + } + + [Fact] + public async Task GetChannelMeta_ReturnsMessageCountAndFlags() + { + var service = CreateService(); + var creator = await SeedUserAsync(); + var create = await service.CreateChannelAsync(creator, "room", null, isPublic: true, password: "secret"); + var channelId = create.Channel!.Id; + + using (var scope = _provider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Messages.Add(new Message + { + Id = Guid.NewGuid(), + Content = "hello", + SentAt = DateTimeOffset.UtcNow, + ChannelId = channelId, + SenderUserId = creator, + SenderUsername = "someone", + }); + await db.SaveChangesAsync(); + } + + var meta = await service.GetChannelMetaAsync("room"); + + Assert.NotNull(meta); + Assert.Equal(1, meta!.MessageCount); + Assert.True(meta.IsProtected); + Assert.False(meta.IsEncrypted); + } +} diff --git a/src/EchoHub.Tests/FileStorageServiceTests.cs b/src/EchoHub.Tests/FileStorageServiceTests.cs new file mode 100644 index 0000000..48e9b5c --- /dev/null +++ b/src/EchoHub.Tests/FileStorageServiceTests.cs @@ -0,0 +1,107 @@ +using System.Text; +using EchoHub.Server.Services; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace EchoHub.Tests; + +/// +/// round-trips against a real temp directory: save, resolve by +/// id (extension-agnostic), bulk id scan, and delete. +/// +public sealed class FileStorageServiceTests : IDisposable +{ + private readonly string _dir; + private readonly FileStorageService _service; + + public FileStorageServiceTests() + { + _dir = Path.Combine(Path.GetTempPath(), "echohub-filestore-" + Guid.NewGuid().ToString("N")); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Storage:Path"] = _dir }) + .Build(); + _service = new FileStorageService(config); + } + + public void Dispose() + { + if (Directory.Exists(_dir)) + Directory.Delete(_dir, recursive: true); + } + + private static Stream StreamOf(string content) => new MemoryStream(Encoding.UTF8.GetBytes(content)); + + [Fact] + public void Constructor_CreatesStorageDirectory() + { + Assert.True(Directory.Exists(_dir)); + } + + [Fact] + public async Task SaveFile_WritesContentAndReturnsResolvablePath() + { + var (fileId, filePath) = await _service.SaveFileAsync(StreamOf("hello world"), "note.txt"); + + Assert.True(File.Exists(filePath)); + Assert.Equal("hello world", await File.ReadAllTextAsync(filePath)); + Assert.Equal(filePath, _service.GetFilePath(fileId)); + } + + [Fact] + public async Task SaveFile_PreservesExtension() + { + var (fileId, _) = await _service.SaveFileAsync(StreamOf("x"), "photo.PNG"); + + var path = _service.GetFilePath(fileId); + + Assert.NotNull(path); + Assert.Equal(".PNG", Path.GetExtension(path)); + } + + [Fact] + public async Task SaveFile_GeneratesDistinctIdsForSameFileName() + { + var (id1, _) = await _service.SaveFileAsync(StreamOf("a"), "dup.txt"); + var (id2, _) = await _service.SaveFileAsync(StreamOf("b"), "dup.txt"); + + Assert.NotEqual(id1, id2); + } + + [Fact] + public void GetFilePath_UnknownId_ReturnsNull() + { + Assert.Null(_service.GetFilePath(Guid.NewGuid().ToString())); + } + + [Fact] + public async Task GetStoredFileIds_ReturnsAllSavedIds() + { + var (id1, _) = await _service.SaveFileAsync(StreamOf("a"), "a.txt"); + var (id2, _) = await _service.SaveFileAsync(StreamOf("b"), "b.bin"); + + var ids = _service.GetStoredFileIds(); + + Assert.Contains(id1, ids); + Assert.Contains(id2, ids); + Assert.Equal(2, ids.Count); + } + + [Fact] + public async Task DeleteFile_RemovesFile() + { + var (fileId, filePath) = await _service.SaveFileAsync(StreamOf("gone soon"), "temp.dat"); + + _service.DeleteFile(fileId); + + Assert.False(File.Exists(filePath)); + Assert.Null(_service.GetFilePath(fileId)); + } + + [Fact] + public void DeleteFile_UnknownId_DoesNotThrow() + { + var ex = Record.Exception(() => _service.DeleteFile(Guid.NewGuid().ToString())); + + Assert.Null(ex); + } +} diff --git a/src/EchoHub.Tests/UserServiceTests.cs b/src/EchoHub.Tests/UserServiceTests.cs new file mode 100644 index 0000000..81ef162 --- /dev/null +++ b/src/EchoHub.Tests/UserServiceTests.cs @@ -0,0 +1,259 @@ +using EchoHub.Core.Constants; +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; + +/// +/// authentication, profile reads, profile-update validation, and +/// avatar. (Registration-gate behavior is covered by .) +/// Runs against a real SQLite in-memory database with real BCrypt hashing. +/// +public sealed class UserServiceTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ServiceProvider _provider; + private readonly UserService _service; + + public UserServiceTests() + { + _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(); + + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Server:Registration"] = "open" }) + .Build(); + _service = new UserService(_provider.GetRequiredService(), config); + } + + public void Dispose() + { + _provider.Dispose(); + _connection.Dispose(); + } + + private async Task RegisterAsync(string username = "alice", string password = "password1") + { + var result = await _service.RegisterUserAsync(username, password); + Assert.True(result.IsSuccess, result.ErrorMessage); + return result.User!; + } + + private EchoHubDbContext Db() => + _provider.GetRequiredService() + .CreateScope().ServiceProvider.GetRequiredService(); + + // ── Authentication ──────────────────────────────────────────────── + + [Fact] + public async Task Authenticate_CorrectCredentials_Succeeds() + { + await RegisterAsync("alice", "password1"); + + var result = await _service.AuthenticateUserAsync("alice", "password1"); + + Assert.True(result.IsSuccess); + Assert.Equal("alice", result.User!.Username); + } + + [Fact] + public async Task Authenticate_IsCaseInsensitiveOnUsername() + { + await RegisterAsync("alice", "password1"); + + var result = await _service.AuthenticateUserAsync("ALICE", "password1"); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task Authenticate_WrongPassword_InvalidCredentials() + { + await RegisterAsync("alice", "password1"); + + var result = await _service.AuthenticateUserAsync("alice", "wrong"); + + Assert.False(result.IsSuccess); + Assert.Equal(UserError.InvalidCredentials, result.Error); + } + + [Fact] + public async Task Authenticate_UnknownUser_InvalidCredentials() + { + var result = await _service.AuthenticateUserAsync("nobody", "password1"); + + Assert.False(result.IsSuccess); + Assert.Equal(UserError.InvalidCredentials, result.Error); + } + + [Fact] + public async Task Authenticate_EmptyInput_ValidationFailed() + { + var result = await _service.AuthenticateUserAsync("", ""); + + Assert.False(result.IsSuccess); + Assert.Equal(UserError.ValidationFailed, result.Error); + } + + [Fact] + public async Task Authenticate_BannedUser_ReturnsBanned() + { + var profile = await RegisterAsync("alice", "password1"); + using (var scope = _provider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.FindAsync(profile.Id); + user!.IsBanned = true; + await db.SaveChangesAsync(); + } + + var result = await _service.AuthenticateUserAsync("alice", "password1"); + + Assert.False(result.IsSuccess); + Assert.Equal(UserError.Banned, result.Error); + } + + // ── Profile reads ───────────────────────────────────────────────── + + [Fact] + public async Task GetUserProfile_KnownUser_ReturnsProfile() + { + await RegisterAsync("alice", "password1"); + + var profile = await _service.GetUserProfileAsync("alice"); + + Assert.NotNull(profile); + Assert.Equal("alice", profile!.Username); + } + + [Fact] + public async Task GetUserProfile_UnknownUser_ReturnsNull() + { + Assert.Null(await _service.GetUserProfileAsync("ghost")); + } + + [Fact] + public async Task GetUserById_RoundTrips() + { + var profile = await RegisterAsync("alice", "password1"); + + var byId = await _service.GetUserByIdAsync(profile.Id); + + Assert.NotNull(byId); + Assert.Equal("alice", byId!.Username); + } + + [Fact] + public async Task GetUserById_UnknownId_ReturnsNull() + { + Assert.Null(await _service.GetUserByIdAsync(Guid.NewGuid())); + } + + // ── Profile updates ─────────────────────────────────────────────── + + [Fact] + public async Task UpdateProfile_ValidFields_Persisted() + { + var profile = await RegisterAsync("alice", "password1"); + + var result = await _service.UpdateProfileAsync(profile.Id, "Alice A", "hi there", "#FF5500"); + + Assert.True(result.IsSuccess); + Assert.Equal("Alice A", result.User!.DisplayName); + Assert.Equal("hi there", result.User.Bio); + Assert.Equal("#FF5500", result.User.NicknameColor); + } + + [Fact] + public async Task UpdateProfile_InvalidHexColor_Rejected() + { + var profile = await RegisterAsync("alice", "password1"); + + var result = await _service.UpdateProfileAsync(profile.Id, null, null, "red"); + + Assert.False(result.IsSuccess); + Assert.Equal(UserError.ValidationFailed, result.Error); + } + + [Fact] + public async Task UpdateProfile_EmptyColor_ClearsIt() + { + var profile = await RegisterAsync("alice", "password1"); + await _service.UpdateProfileAsync(profile.Id, null, null, "#FF5500"); + + var result = await _service.UpdateProfileAsync(profile.Id, null, null, ""); + + Assert.True(result.IsSuccess); + Assert.Null(result.User!.NicknameColor); + } + + [Fact] + public async Task UpdateProfile_DisplayNameTooLong_Rejected() + { + var profile = await RegisterAsync("alice", "password1"); + + var result = await _service.UpdateProfileAsync( + profile.Id, new string('x', ValidationConstants.MaxDisplayNameLength + 1), null, null); + + Assert.False(result.IsSuccess); + Assert.Equal(UserError.ValidationFailed, result.Error); + } + + [Fact] + public async Task UpdateProfile_BioTooLong_Rejected() + { + var profile = await RegisterAsync("alice", "password1"); + + var result = await _service.UpdateProfileAsync( + profile.Id, null, new string('x', ValidationConstants.MaxBioLength + 1), null); + + Assert.False(result.IsSuccess); + Assert.Equal(UserError.ValidationFailed, result.Error); + } + + [Fact] + public async Task UpdateProfile_UnknownUser_NotFound() + { + var result = await _service.UpdateProfileAsync(Guid.NewGuid(), "x", null, null); + + Assert.False(result.IsSuccess); + Assert.Equal(UserError.NotFound, result.Error); + } + + // ── Avatar ──────────────────────────────────────────────────────── + + [Fact] + public async Task SetAvatar_Persisted() + { + var profile = await RegisterAsync("alice", "password1"); + + var result = await _service.SetAvatarAsync(profile.Id, "{F:FF0000}art"); + + Assert.True(result.IsSuccess); + var stored = await Db().Users.FindAsync(profile.Id); + Assert.Equal("{F:FF0000}art", stored!.AvatarAscii); + } + + [Fact] + public async Task SetAvatar_UnknownUser_NotFound() + { + var result = await _service.SetAvatarAsync(Guid.NewGuid(), "art"); + + Assert.False(result.IsSuccess); + Assert.Equal(UserError.NotFound, result.Error); + } +}