feat: add unit tests for ChannelService, FileStorageService, and UserService

This commit is contained in:
HueByte
2026-07-17 20:59:59 +02:00
parent 38eca99fb1
commit 46afd2a195
3 changed files with 807 additions and 0 deletions
+441
View File
@@ -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;
/// <summary>
/// General <see cref="ChannelService"/> CRUD, validation, password gates, and role/creator
/// authorization. (System-channel behavior lives in <see cref="ChannelServiceSystemChannelTests"/>.)
/// Runs against a real SQLite in-memory database so the guarded queries and FK relationships
/// behave as in production.
/// </summary>
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<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 ChannelService CreateService() => new(
_provider.GetRequiredService<IServiceScopeFactory>(),
new PresenceTracker(),
new SpamGuard(new SpamOptions { Enabled = false }),
_serverLogs,
NullLogger<ChannelService>.Instance);
private async Task<Guid> SeedUserAsync(ServerRole role = ServerRole.Member)
{
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<IServiceScopeFactory>()
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
// ── 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<EchoHubDbContext>();
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);
}
}
@@ -0,0 +1,107 @@
using System.Text;
using EchoHub.Server.Services;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// <see cref="FileStorageService"/> round-trips against a real temp directory: save, resolve by
/// id (extension-agnostic), bulk id scan, and delete.
/// </summary>
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<string, string?> { ["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);
}
}
+259
View File
@@ -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;
/// <summary>
/// <see cref="UserService"/> authentication, profile reads, profile-update validation, and
/// avatar. (Registration-gate behavior is covered by <see cref="UserServiceRegistrationTests"/>.)
/// Runs against a real SQLite in-memory database with real BCrypt hashing.
/// </summary>
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<EchoHubDbContext>(o => o.UseSqlite(_connection));
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
scope.ServiceProvider.GetRequiredService<EchoHubDbContext>().Database.EnsureCreated();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Server:Registration"] = "open" })
.Build();
_service = new UserService(_provider.GetRequiredService<IServiceScopeFactory>(), config);
}
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
private async Task<UserProfileDto> 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<IServiceScopeFactory>()
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
// ── 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<EchoHubDbContext>();
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);
}
}