using Ikar.Server.Controllers; using Ikar.Server.Data.Entities; using Ikar.Shared; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; namespace Ikar.Server.Tests; public sealed class ChannelMembersControllerTests { [Fact] public async Task GetPublicChannelByUsername_ReturnsPreviewForNonMember() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var viewer = CreateUser("viewer"); var channel = CreateChannel(owner); channel.PublicUsername = "newsroom"; dbContext.AddRange(owner, viewer, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); var response = await controller.GetPublicChannelByUsername("@newsroom", CancellationToken.None); var details = ControllerTestFixture.ValueOf(response); Assert.Equal(channel.Id, details.Id); Assert.Equal(ChatType.Channel, details.Type); Assert.False(details.IsSubscribed); Assert.True(details.CanSubscribe); Assert.Equal("newsroom", details.PublicUsername); } [Fact] public async Task SearchPage_ReturnsPublicChannelForNonMember() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var viewer = CreateUser("viewer"); var channel = CreateChannel(owner); channel.Title = "City News"; channel.PublicUsername = "city_news"; channel.Description = "Daily city updates"; dbContext.AddRange(owner, viewer, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); var response = await controller.SearchPage( q: "city", filter: null, type: null, page: 1, limit: 20, date: null, dateFrom: null, dateTo: null, authorId: null, author: null, CancellationToken.None); var page = ControllerTestFixture.ValueOf(response); var result = Assert.Single(page.Results, x => x.Chat.Id == channel.Id); Assert.Null(result.Message); Assert.Equal(ChatType.Channel, result.Chat.Type); Assert.Equal("city_news", result.Chat.PublicUsername); Assert.True(result.Chat.CanSubscribe); Assert.False(result.Chat.IsSubscribed); } [Fact] public async Task AddMembers_CanAddBotAsChannelAdmin() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var bot = CreateUser("news_bot", isBot: true); var channel = CreateChannel(owner); dbContext.AddRange(owner, bot, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.AddMembers( channel.Id, new AddChatMembersRequest([bot.Id], ChatMemberRole.Admin), CancellationToken.None); var details = ControllerTestFixture.ValueOf(response); var members = details.Members ?? throw new InvalidOperationException("Chat members were not returned."); var botMember = Assert.Single(members, member => member.User.Id == bot.Id); Assert.True(botMember.User.IsBot); Assert.Equal(ChatMemberRole.Admin, botMember.Role); Assert.True(botMember.Permissions.CanManageMembers); Assert.True(botMember.Permissions.CanPostMessages); Assert.True(botMember.Permissions.CanPinMessages); Assert.True(botMember.Permissions.CanDeleteMessages); Assert.True(botMember.Permissions.CanInviteUsers); Assert.Equal(2, details.SubscriberCount); Assert.Equal(2, details.AdminCount); } [Fact] public async Task AddMembers_CanAddMemberToGroup() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var member = CreateUser("member"); var group = CreateGroup(owner); dbContext.AddRange(owner, member, group); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.AddMembers( group.Id, new AddChatMembersRequest([member.Id]), CancellationToken.None); var details = ControllerTestFixture.ValueOf(response); var addedMember = Assert.Single(details.Members ?? [], x => x.User.Id == member.Id); Assert.Equal(ChatMemberRole.Member, addedMember.Role); Assert.False(addedMember.IsOwner); Assert.True(addedMember.Permissions.CanPostMessages); Assert.Equal(2, details.SubscriberCount); } [Fact] public async Task AddMembers_DeniesWhenCandidateBlockedAdmin() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var member = CreateUser("member"); var group = CreateGroup(owner); dbContext.AddRange( owner, member, group, new UserPrivacySettings { UserId = member.Id, User = member, BlockedUserIds = owner.Id.ToString() }); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.AddMembers( group.Id, new AddChatMembersRequest([member.Id]), CancellationToken.None); var forbidden = Assert.IsType(response.Result); Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == group.Id && x.UserId == member.Id); } [Fact] public async Task SendMessage_DeniesRestrictedGroupMember() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var member = CreateUser("member"); var group = CreateGroup(owner); group.Members.Add(new ChatMember { ChatId = group.Id, Chat = group, UserId = member.Id, User = member, Role = ChatMemberRole.Member, CanPostMessages = false }); dbContext.AddRange(owner, member, group); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id); var response = await controller.SendMessage( group.Id, new SendMessageRequest("test"), CancellationToken.None); var forbidden = Assert.IsType(response.Result); Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); } [Fact] public async Task RemoveMember_CanRemoveRegularChannelMember() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var member = CreateUser("member"); var channel = CreateChannel(owner); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = member.Id, User = member, Role = ChatMemberRole.Member }); dbContext.AddRange(owner, member, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.RemoveMember(channel.Id, member.Id, CancellationToken.None); var details = ControllerTestFixture.ValueOf(response); Assert.DoesNotContain(details.Members ?? [], x => x.User.Id == member.Id); Assert.False(dbContext.ChatMembers.Any(x => x.ChatId == channel.Id && x.UserId == member.Id)); Assert.Equal(1, details.SubscriberCount); } [Fact] public async Task JoinByInviteLink_ReturnsPendingApprovalResultForModeratedInvite() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var requester = CreateUser("requester"); var channel = CreateChannel(owner); var invite = new ChatInviteLink { ChatId = channel.Id, Chat = channel, CreatedById = owner.Id, CreatedBy = owner, Token = "moderated-token", CreatesJoinRequest = true }; channel.InviteLinks.Add(invite); dbContext.AddRange(owner, requester, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, requester.Id); var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None); var accepted = Assert.IsType(response.Result); var result = Assert.IsType(accepted.Value); Assert.Equal(JoinChatResultStatus.PendingApproval, result.Status); Assert.NotNull(result.JoinRequestId); Assert.Equal(ChatJoinRequestStatus.Pending, result.JoinRequestStatus); Assert.Null(result.Chat); Assert.Single(dbContext.ChatJoinRequests, x => x.ChatId == channel.Id && x.UserId == requester.Id); } [Fact] public async Task CreateInviteLink_ReturnsExpirationAndUsageLimits() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); dbContext.AddRange(owner, channel); await dbContext.SaveChangesAsync(); var expiresAt = DateTimeOffset.UtcNow.AddHours(2); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.CreateInviteLink( channel.Id, new CreateChatInviteLinkRequest( Title: "limited", CreatesJoinRequest: false, ExpiresAt: expiresAt, MaxUses: 3, MemberLimit: 10), CancellationToken.None); var link = ControllerTestFixture.ValueOf(response); Assert.Equal("limited", link.Title); Assert.False(link.CreatesJoinRequest); Assert.Equal(expiresAt, link.ExpiresAt); Assert.Equal(3, link.MaxUses); Assert.Equal(0, link.UsesCount); Assert.Equal(10, link.MemberLimit); var stored = Assert.Single(dbContext.ChatInviteLinks, x => x.ChatId == channel.Id); Assert.Equal(expiresAt, stored.ExpiresAt); Assert.Equal(3, stored.MaxUses); Assert.Equal(0, stored.UsesCount); Assert.Equal(10, stored.MemberLimit); } [Fact] public async Task JoinByInviteLink_RejectsExpiredLink() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var viewer = CreateUser("viewer"); var channel = CreateChannel(owner); var invite = new ChatInviteLink { ChatId = channel.Id, Chat = channel, CreatedById = owner.Id, CreatedBy = owner, Token = "expired-token", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-1) }; channel.InviteLinks.Add(invite); dbContext.AddRange(owner, viewer, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None); var badRequest = Assert.IsType(response.Result); Assert.Equal("Invite link has expired.", badRequest.Value); Assert.Equal(0, invite.UsesCount); Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == viewer.Id); } [Fact] public async Task JoinByInviteLink_IncrementsUsesAndRejectsAfterMaxUses() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var firstViewer = CreateUser("first"); var secondViewer = CreateUser("second"); var channel = CreateChannel(owner); var invite = new ChatInviteLink { ChatId = channel.Id, Chat = channel, CreatedById = owner.Id, CreatedBy = owner, Token = "single-use-token", MaxUses = 1 }; channel.InviteLinks.Add(invite); dbContext.AddRange(owner, firstViewer, secondViewer, channel); await dbContext.SaveChangesAsync(); var firstController = ControllerTestFixture.CreateChatsController(dbContext, firstViewer.Id); var firstResponse = await firstController.JoinByInviteLink(invite.Token, CancellationToken.None); var firstResult = ControllerTestFixture.ValueOf(firstResponse); Assert.Equal(JoinChatResultStatus.Joined, firstResult.Status); Assert.Equal(1, invite.UsesCount); Assert.Contains(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == firstViewer.Id); var secondController = ControllerTestFixture.CreateChatsController(dbContext, secondViewer.Id); var secondResponse = await secondController.JoinByInviteLink(invite.Token, CancellationToken.None); var badRequest = Assert.IsType(secondResponse.Result); Assert.Equal("Invite link usage limit reached.", badRequest.Value); Assert.Equal(1, invite.UsesCount); Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == secondViewer.Id); } [Fact] public async Task JoinByInviteLink_RejectsWhenMemberLimitReached() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var viewer = CreateUser("viewer"); var channel = CreateChannel(owner); var invite = new ChatInviteLink { ChatId = channel.Id, Chat = channel, CreatedById = owner.Id, CreatedBy = owner, Token = "full-token", MemberLimit = 1 }; channel.InviteLinks.Add(invite); dbContext.AddRange(owner, viewer, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, viewer.Id); var response = await controller.JoinByInviteLink(invite.Token, CancellationToken.None); var badRequest = Assert.IsType(response.Result); Assert.Equal("Chat member limit reached.", badRequest.Value); Assert.Equal(0, invite.UsesCount); Assert.DoesNotContain(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == viewer.Id); } [Fact] public async Task UpdateMemberPermissions_CannotPromoteMemberToOwner() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var member = CreateUser("member"); var channel = CreateChannel(owner); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = member.Id, User = member, IsOwner = false, Role = ChatMemberRole.Member }); dbContext.AddRange(owner, member, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.UpdateMemberPermissions( channel.Id, member.Id, new UpdateChatMemberPermissionsRequest( ChatMemberRole.Owner, CanPinMessages: true, CanDeleteMessages: true, CanInviteUsers: true, CanPostMessages: true), CancellationToken.None); var badRequest = Assert.IsType(response.Result); Assert.Equal("Ownership can only be transferred by a dedicated owner transfer action.", badRequest.Value); var members = dbContext.ChatMembers.Where(x => x.ChatId == channel.Id).ToList(); Assert.Single(members, x => x.IsOwner || x.Role == ChatMemberRole.Owner); Assert.Equal(ChatMemberRole.Member, Assert.Single(members, x => x.UserId == member.Id).Role); } [Fact] public async Task UpdateChatState_CanLinkAndClearChannelDiscussionGroup() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); var group = CreateGroup(owner); dbContext.AddRange(owner, channel, group); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var linkResponse = await controller.UpdateChatState( channel.Id, new ChatStateRequest(LinkedDiscussionChatId: group.Id), CancellationToken.None); var linked = ControllerTestFixture.ValueOf(linkResponse); Assert.Equal(group.Id, linked.LinkedDiscussionChatId); Assert.Equal(group.Id, dbContext.Chats.Single(x => x.Id == channel.Id).LinkedDiscussionChatId); var clearResponse = await controller.UpdateChatState( channel.Id, new ChatStateRequest(ClearLinkedDiscussionChat: true), CancellationToken.None); var cleared = ControllerTestFixture.ValueOf(clearResponse); Assert.Null(cleared.LinkedDiscussionChatId); Assert.Null(dbContext.Chats.Single(x => x.Id == channel.Id).LinkedDiscussionChatId); } [Fact] public async Task SendMessage_ChannelWithLinkedDiscussion_CreatesDiscussionRootAndCountsNestedReplies() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); var group = CreateGroup(owner); channel.LinkedDiscussionChatId = group.Id; dbContext.AddRange(owner, channel, group); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var postResponse = await controller.SendMessage( channel.Id, new SendMessageRequest("Breaking news"), CancellationToken.None); var postDto = ControllerTestFixture.ValueOf(postResponse); Assert.NotNull(postDto.DiscussionMessageId); Assert.Equal(0, postDto.CommentCount); var rootMessage = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); Assert.Equal(group.Id, rootMessage.ChatId); Assert.Equal("Breaking news", rootMessage.Text); Assert.Equal(channel.Title, rootMessage.ForwardedFromDisplayName); var firstReplyResponse = await controller.SendMessage( group.Id, new SendMessageRequest("First comment", ReplyToMessageId: rootMessage.Id), CancellationToken.None); var firstReply = ControllerTestFixture.ValueOf(firstReplyResponse); await controller.SendMessage( group.Id, new SendMessageRequest("Nested comment", ReplyToMessageId: firstReply.Id), CancellationToken.None); var storedPost = dbContext.Messages.Single(x => x.Id == postDto.Id); Assert.Equal(2, storedPost.CommentCount); } [Fact] public async Task UploadAttachment_ChannelWithLinkedDiscussion_MirrorsAttachmentToDiscussionRoot() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-channel-upload-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempRoot); try { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); var group = CreateGroup(owner); channel.LinkedDiscussionChatId = group.Id; dbContext.AddRange(owner, channel, group); await dbContext.SaveChangesAsync(); var storage = ControllerTestFixture.CreateAttachmentStorageService(tempRoot); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id, storage); var response = await controller.UploadAttachment( channel.Id, new UploadAttachmentRequest { Text = "Photo post", File = CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "photo.jpg", "image/jpeg") }, CancellationToken.None); var postDto = ControllerTestFixture.ValueOf(response); Assert.NotNull(postDto.DiscussionMessageId); var discussionRoot = dbContext.Messages .Include(x => x.Attachments) .Single(x => x.Id == postDto.DiscussionMessageId); var mirroredAttachment = Assert.Single(discussionRoot.Attachments); Assert.Equal(group.Id, discussionRoot.ChatId); Assert.Equal("Photo post", discussionRoot.Text); Assert.Equal("photo.jpg", mirroredAttachment.OriginalFileName); Assert.Equal("image/jpeg", mirroredAttachment.ContentType); Assert.True(File.Exists(mirroredAttachment.StoragePath)); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task UploadAttachmentAlbum_ChannelWithLinkedDiscussion_MirrorsAlbumToDiscussionRoot() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-channel-album-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempRoot); try { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); var group = CreateGroup(owner); channel.LinkedDiscussionChatId = group.Id; dbContext.AddRange(owner, channel, group); await dbContext.SaveChangesAsync(); var storage = ControllerTestFixture.CreateAttachmentStorageService(tempRoot); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id, storage); var response = await controller.UploadAttachmentAlbum( channel.Id, new UploadAttachmentAlbumRequest { Text = "Gallery", Files = [ CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "first.jpg", "image/jpeg"), CreateFormFile([0xFF, 0xD8, 0xFF, 0xE1], "second.jpg", "image/jpeg") ] }, CancellationToken.None); var postDto = ControllerTestFixture.ValueOf(response); Assert.NotNull(postDto.DiscussionMessageId); var channelPost = dbContext.Messages.Single(x => x.Id == postDto.Id); var discussionRoot = dbContext.Messages .Include(x => x.Attachments) .Single(x => x.Id == postDto.DiscussionMessageId); Assert.Equal(channelPost.MediaAlbumId, discussionRoot.MediaAlbumId); Assert.Equal( new[] { "first.jpg", "second.jpg" }, discussionRoot.Attachments.OrderBy(x => x.SortOrder).Select(x => x.OriginalFileName)); Assert.All(discussionRoot.Attachments, attachment => Assert.True(File.Exists(attachment.StoragePath))); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task UpdateMessage_ChannelPostWithLinkedDiscussion_UpdatesDiscussionRootText() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); var group = CreateGroup(owner); channel.LinkedDiscussionChatId = group.Id; dbContext.AddRange(owner, channel, group); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var postResponse = await controller.SendMessage( channel.Id, new SendMessageRequest("Old text"), CancellationToken.None); var postDto = ControllerTestFixture.ValueOf(postResponse); var updateResponse = await controller.UpdateMessage( channel.Id, postDto.Id, new UpdateMessageRequest("New text"), CancellationToken.None); var updatedDto = ControllerTestFixture.ValueOf(updateResponse); var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); Assert.Equal("New text", updatedDto.Text); Assert.Equal("New text", discussionRoot.Text); Assert.NotNull(discussionRoot.EditedAt); } [Fact] public async Task DeleteMessage_ChannelPostWithLinkedDiscussion_DeletesDiscussionRoot() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); var group = CreateGroup(owner); channel.LinkedDiscussionChatId = group.Id; dbContext.AddRange(owner, channel, group); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var postResponse = await controller.SendMessage( channel.Id, new SendMessageRequest("Post to delete"), CancellationToken.None); var postDto = ControllerTestFixture.ValueOf(postResponse); var deleteResponse = await controller.DeleteMessage(channel.Id, postDto.Id, CancellationToken.None); var deletedDto = ControllerTestFixture.ValueOf(deleteResponse); var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); Assert.NotNull(deletedDto.DeletedAt); Assert.NotNull(discussionRoot.DeletedAt); Assert.Equal(string.Empty, discussionRoot.Text); } [Fact] public async Task UpdateChatState_CanUpdateChannelProfile() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); dbContext.AddRange(owner, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.UpdateChatState( channel.Id, new ChatStateRequest( Title: "Daily News", Description: "Verified city updates", PublicUsername: "daily_news"), CancellationToken.None); var summary = ControllerTestFixture.ValueOf(response); var stored = dbContext.Chats.Single(x => x.Id == channel.Id); Assert.Equal("Daily News", summary.Title); Assert.Equal("Daily News", stored.Title); Assert.Equal("Verified city updates", stored.Description); Assert.Equal("daily_news", stored.PublicUsername); Assert.Contains(dbContext.ModerationLogEntries, x => x.ChatId == channel.Id && x.ActorUserId == owner.Id && x.Action == "chat.profile.updated"); } [Fact] public async Task UpdateChatState_CanClearChannelDescriptionAndPublicUsername() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); channel.Description = "Old description"; channel.PublicUsername = "old_news"; dbContext.AddRange(owner, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.UpdateChatState( channel.Id, new ChatStateRequest(ClearDescription: true, ClearPublicUsername: true), CancellationToken.None); ControllerTestFixture.ValueOf(response); var stored = dbContext.Chats.Single(x => x.Id == channel.Id); Assert.Null(stored.Description); Assert.Null(stored.PublicUsername); } [Fact] public async Task UpdateChatState_DeniesMemberProfileUpdate() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var member = CreateUser("member"); var channel = CreateChannel(owner); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = member.Id, User = member, Role = ChatMemberRole.Member }); dbContext.AddRange(owner, member, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id); var response = await controller.UpdateChatState( channel.Id, new ChatStateRequest(Title: "Member title"), CancellationToken.None); var forbidden = Assert.IsType(response.Result); Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); Assert.Equal("Only admins can update chat profile.", forbidden.Value); Assert.Equal("News", dbContext.Chats.Single(x => x.Id == channel.Id).Title); } [Fact] public async Task UpdateChatState_RejectsTakenPublicUsername() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var otherOwner = CreateUser("other"); var channel = CreateChannel(owner); var otherChannel = CreateChannel(otherOwner); otherChannel.PublicUsername = "taken_name"; dbContext.AddRange(owner, otherOwner, channel, otherChannel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.UpdateChatState( channel.Id, new ChatStateRequest(PublicUsername: "taken_name"), CancellationToken.None); var conflict = Assert.IsType(response.Result); Assert.Equal("Public username is already taken.", conflict.Value); Assert.Equal("news", dbContext.Chats.Single(x => x.Id == channel.Id).PublicUsername); } [Fact] public async Task TransferOwnership_MovesOwnerRoleToChannelMember() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var member = CreateUser("member"); var channel = CreateChannel(owner); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = member.Id, User = member, IsOwner = false, Role = ChatMemberRole.Member }); dbContext.AddRange(owner, member, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.TransferOwnership(channel.Id, member.Id, CancellationToken.None); var details = ControllerTestFixture.ValueOf(response); Assert.Equal(member.Id, dbContext.Chats.Single(x => x.Id == channel.Id).CreatedById); var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id); var targetMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == member.Id); Assert.False(ownerMember.IsOwner); Assert.Equal(ChatMemberRole.Admin, ownerMember.Role); Assert.True(ownerMember.CanPostMessages); Assert.True(targetMember.IsOwner); Assert.Equal(ChatMemberRole.Owner, targetMember.Role); Assert.True(targetMember.CanInviteUsers); Assert.Single(dbContext.ChatMembers.Where(x => x.ChatId == channel.Id && (x.IsOwner || x.Role == ChatMemberRole.Owner))); var returnedTarget = Assert.Single(details.Members ?? [], x => x.User.Id == member.Id); Assert.True(returnedTarget.IsOwner); Assert.Equal(ChatMemberRole.Owner, returnedTarget.Role); Assert.Contains(dbContext.ModerationLogEntries, x => x.ChatId == channel.Id && x.ActorUserId == owner.Id && x.TargetId == member.Id && x.Action == "member.owner.transferred"); } [Fact] public async Task TransferOwnership_RejectsAdminActor() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var admin = CreateUser("admin"); var member = CreateUser("member"); var channel = CreateChannel(owner); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = admin.Id, User = admin, IsOwner = false, Role = ChatMemberRole.Admin, CanPinMessages = true, CanDeleteMessages = true, CanInviteUsers = true, CanPostMessages = true }); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = member.Id, User = member, IsOwner = false, Role = ChatMemberRole.Member }); dbContext.AddRange(owner, admin, member, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, admin.Id); var response = await controller.TransferOwnership(channel.Id, member.Id, CancellationToken.None); var forbidden = Assert.IsType(response.Result); Assert.Equal(Microsoft.AspNetCore.Http.StatusCodes.Status403Forbidden, forbidden.StatusCode); Assert.Equal("Only owner can transfer ownership.", forbidden.Value); var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id); Assert.True(ownerMember.IsOwner); Assert.Equal(ChatMemberRole.Owner, ownerMember.Role); } [Fact] public async Task TransferOwnership_RejectsBotTarget() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var bot = CreateUser("bot", isBot: true); var channel = CreateChannel(owner); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = bot.Id, User = bot, IsOwner = false, Role = ChatMemberRole.Admin, CanPinMessages = true, CanDeleteMessages = true, CanInviteUsers = true, CanPostMessages = true }); dbContext.AddRange(owner, bot, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.TransferOwnership(channel.Id, bot.Id, CancellationToken.None); var badRequest = Assert.IsType(response.Result); Assert.Equal("Bot cannot become chat owner.", badRequest.Value); var ownerMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == owner.Id); var botMember = Assert.Single(dbContext.ChatMembers, x => x.ChatId == channel.Id && x.UserId == bot.Id); Assert.True(ownerMember.IsOwner); Assert.False(botMember.IsOwner); Assert.Equal(ChatMemberRole.Admin, botMember.Role); } [Fact] public async Task DeleteChat_BlocksOwnerLeavingChannelWithoutDeletingForEveryone() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var member = CreateUser("member"); var channel = CreateChannel(owner); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = member.Id, User = member, IsOwner = false, Role = ChatMemberRole.Member }); dbContext.AddRange(owner, member, channel); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.DeleteChat(channel.Id, forEveryone: false, CancellationToken.None); var badRequest = Assert.IsType(response); Assert.Equal("Owner must transfer ownership or delete the chat for everyone.", badRequest.Value); Assert.Equal(2, dbContext.ChatMembers.Count(x => x.ChatId == channel.Id)); Assert.True(dbContext.Chats.Any(x => x.Id == channel.Id)); } [Fact] public async Task UpdateMessage_AllowsChannelAdminWithPostPermissionToEditChannelPost() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var admin = CreateUser("admin"); var channel = CreateChannel(owner); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = admin.Id, User = admin, Role = ChatMemberRole.Admin, CanPostMessages = true }); var message = CreateTextMessage(channel, owner, "Original"); dbContext.AddRange(owner, admin, channel, message); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, admin.Id); var response = await controller.UpdateMessage( channel.Id, message.Id, new UpdateMessageRequest("Updated by admin"), CancellationToken.None); var updated = ControllerTestFixture.ValueOf(response); Assert.Equal("Updated by admin", updated.Text); Assert.NotNull(dbContext.Messages.Single(x => x.Id == message.Id).EditedAt); } [Fact] public async Task UpdateMessage_DeniesChannelMemberWithoutPostPermission() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var member = CreateUser("member"); var channel = CreateChannel(owner); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = member.Id, User = member, Role = ChatMemberRole.Member, CanPostMessages = false }); var message = CreateTextMessage(channel, owner, "Original"); dbContext.AddRange(owner, member, channel, message); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, member.Id); var response = await controller.UpdateMessage( channel.Id, message.Id, new UpdateMessageRequest("Updated by admin"), CancellationToken.None); var forbidden = Assert.IsType(response.Result); Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); Assert.Equal("You cannot edit this message.", forbidden.Value); Assert.Equal("Original", dbContext.Messages.Single(x => x.Id == message.Id).Text); } [Fact] public async Task DownloadAttachment_DoesNotServeDeletedMessageAttachment() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); var message = CreateMessageWithAttachment(channel, owner, deletedAt: DateTimeOffset.UtcNow); dbContext.AddRange(owner, channel, message); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.DownloadAttachment(channel.Id, message.Attachments.Single().Id, CancellationToken.None); Assert.IsType(response); } [Fact] public async Task UploadAttachmentAlbum_RejectsSingleFile() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var group = CreateGroup(owner); dbContext.AddRange(owner, group); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.UploadAttachmentAlbum( group.Id, new UploadAttachmentAlbumRequest { Files = [CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg")] }, CancellationToken.None); var badRequest = Assert.IsType(response.Result); Assert.Equal("Albums must contain at least 2 files.", badRequest.Value); } [Fact] public async Task UploadAttachmentAlbum_RejectsDocumentFile() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var group = CreateGroup(owner); dbContext.AddRange(owner, group); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.UploadAttachmentAlbum( group.Id, new UploadAttachmentAlbumRequest { Files = [ CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg"), CreateFormFile([0x25, 0x50, 0x44, 0x46], "manual.pdf", "application/pdf") ] }, CancellationToken.None); var badRequest = Assert.IsType(response.Result); Assert.Equal("Albums accept only image and video files.", badRequest.Value); } [Fact] public async Task DownloadAttachment_DoesNotServeExpiredMessageAttachment() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); var owner = CreateUser("owner"); var channel = CreateChannel(owner); var message = CreateMessageWithAttachment(channel, owner, expiresAt: DateTimeOffset.UtcNow.AddSeconds(-1)); dbContext.AddRange(owner, channel, message); await dbContext.SaveChangesAsync(); var controller = ControllerTestFixture.CreateChatsController(dbContext, owner.Id); var response = await controller.DownloadAttachment(channel.Id, message.Attachments.Single().Id, CancellationToken.None); Assert.IsType(response); } private static User CreateUser(string username, bool isBot = false) => new() { Id = Guid.NewGuid(), Username = username, NormalizedUsername = username.ToUpperInvariant(), DisplayName = username, PasswordHash = "test", IsBot = isBot }; private static Chat CreateChannel(User owner) { var channel = new Chat { Id = Guid.NewGuid(), Type = ChatType.Channel, Title = "News", Description = "Public news channel", PublicUsername = "news", CreatedById = owner.Id, CreatedBy = owner, CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) }; channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = owner.Id, User = owner, IsOwner = true, Role = ChatMemberRole.Owner, CanPinMessages = true, CanDeleteMessages = true, CanInviteUsers = true, CanPostMessages = true }); return channel; } private static Chat CreateGroup(User owner) { var group = new Chat { Id = Guid.NewGuid(), Type = ChatType.Group, Title = "Group", CreatedById = owner.Id, CreatedBy = owner, CreatedAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero), LastActivityAt = new DateTimeOffset(2026, 1, 1, 9, 0, 0, TimeSpan.Zero) }; group.Members.Add(new ChatMember { ChatId = group.Id, Chat = group, UserId = owner.Id, User = owner, IsOwner = true, Role = ChatMemberRole.Owner, CanPinMessages = true, CanDeleteMessages = true, CanInviteUsers = true, CanPostMessages = true }); return group; } private static FormFile CreateFormFile(byte[] content, string fileName, string contentType) { var stream = new MemoryStream(content); return new FormFile(stream, 0, stream.Length, "files", fileName) { Headers = new HeaderDictionary(), ContentType = contentType }; } private static Message CreateTextMessage(Chat chat, User sender, string text) { var message = new Message { Id = Guid.NewGuid(), ChatId = chat.Id, Chat = chat, SenderId = sender.Id, Sender = sender, Text = text, SentAt = DateTimeOffset.UtcNow }; chat.Messages.Add(message); return message; } private static Message CreateMessageWithAttachment( Chat chat, User sender, DateTimeOffset? deletedAt = null, DateTimeOffset? expiresAt = null) { var message = new Message { Id = Guid.NewGuid(), ChatId = chat.Id, Chat = chat, SenderId = sender.Id, Sender = sender, Text = string.Empty, SentAt = DateTimeOffset.UtcNow, DeletedAt = deletedAt, ExpiresAt = expiresAt }; message.Attachments.Add(new MessageAttachment { Id = Guid.NewGuid(), MessageId = message.Id, Message = message, OriginalFileName = "document.pdf", ContentType = "application/pdf", StoragePath = "missing.pdf" }); return message; } }