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);
+ }
+}