Files
IKAR/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs
T

1204 lines
49 KiB
C#

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<Microsoft.AspNetCore.Mvc.ObjectResult>(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<Microsoft.AspNetCore.Mvc.ObjectResult>(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<Microsoft.AspNetCore.Mvc.AcceptedResult>(response.Result);
var result = Assert.IsType<JoinChatResultDto>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.ObjectResult>(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<Microsoft.AspNetCore.Mvc.ConflictObjectResult>(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<Microsoft.AspNetCore.Mvc.ObjectResult>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.ObjectResult>(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<Microsoft.AspNetCore.Mvc.NotFoundResult>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.NotFoundResult>(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;
}
}