using Ikar.Server.Controllers; using Ikar.Server.Data; using Ikar.Server.Data.Entities; using Ikar.Server.Infrastructure; using Ikar.Server.Infrastructure.Auth; using Ikar.Server.Infrastructure.Bots; using Ikar.Server.Infrastructure.Hubs; using Ikar.Server.Infrastructure.Push; using Ikar.Server.Infrastructure.Storage; using Ikar.Shared; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.FileProviders; namespace Ikar.Server.Tests; public sealed class BotApiControllerTests { [Fact] public async Task SendDocument_DecodesPercentEncodedFileName() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("documentbot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var chat = CreateDirectChat(owner, botUser); dbContext.AddRange(owner, botUser, bot, chat); await dbContext.SaveChangesAsync(); await using var stream = new MemoryStream("%PDF-1.3"u8.ToArray()); var encodedFileName = "Fluke_8508A_" + Uri.EscapeDataString("методика.pdf"); var file = new FormFile(stream, 0, stream.Length, "file", encodedFileName) { Headers = new HeaderDictionary(), ContentType = "application/pdf" }; var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendDocument( token, new BotSendAttachmentRequest { ChatId = chat.Id, Text = "", File = file }, CancellationToken.None); var message = ControllerTestFixture.ValueOf(response); var attachment = Assert.Single(message.Attachments); Assert.Equal("Fluke_8508A_методика.pdf", attachment.FileName); Assert.Equal("application/pdf", attachment.ContentType); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendDocument_SavesOggAsVoiceNoteAttachment() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("documentbot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var chat = CreateDirectChat(owner, botUser); dbContext.AddRange(owner, botUser, bot, chat); await dbContext.SaveChangesAsync(); await using var stream = new MemoryStream([1, 2, 3, 4]); var file = new FormFile(stream, 0, stream.Length, "file", "song.ogg") { Headers = new HeaderDictionary(), ContentType = "audio/ogg" }; var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendDocument( token, new BotSendAttachmentRequest { ChatId = chat.Id, Text = "", File = file }, CancellationToken.None); var message = ControllerTestFixture.ValueOf(response); var attachment = Assert.Single(message.Attachments); Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); Assert.Equal("audio/ogg", attachment.ContentType); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendPhoto_SavesImageAttachment() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("photobot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var chat = CreateDirectChat(owner, botUser); dbContext.AddRange(owner, botUser, bot, chat); await dbContext.SaveChangesAsync(); await using var stream = new MemoryStream([0xFF, 0xD8, 0xFF, 0xD9]); var file = new FormFile(stream, 0, stream.Length, "file", "photo.jpg") { Headers = new HeaderDictionary(), ContentType = "image/jpeg" }; var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendPhoto( token, new BotSendAttachmentRequest { ChatId = chat.Id, Text = "caption", File = file }, CancellationToken.None); var message = ControllerTestFixture.ValueOf(response); Assert.Equal("caption", message.Text); var attachment = Assert.Single(message.Attachments); Assert.Equal(AttachmentKind.File, attachment.Kind); Assert.Equal("image/jpeg", attachment.ContentType); Assert.Equal("photo.jpg", attachment.FileName); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendPhoto_RejectsVideoAttachment() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("photobot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var chat = CreateDirectChat(owner, botUser); dbContext.AddRange(owner, botUser, bot, chat); await dbContext.SaveChangesAsync(); await using var stream = new MemoryStream([1, 2, 3, 4]); var file = new FormFile(stream, 0, stream.Length, "file", "clip.mp4") { Headers = new HeaderDictionary(), ContentType = "video/mp4" }; var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendPhoto( token, new BotSendAttachmentRequest { ChatId = chat.Id, Text = "", File = file }, CancellationToken.None); var badRequest = Assert.IsType(response.Result); Assert.Equal("sendPhoto accepts only image files.", badRequest.Value); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendVideo_SavesVideoAttachment() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("videobot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var chat = CreateDirectChat(owner, botUser); dbContext.AddRange(owner, botUser, bot, chat); await dbContext.SaveChangesAsync(); await using var stream = new MemoryStream([0, 0, 0, 24, 102, 116, 121, 112]); var file = new FormFile(stream, 0, stream.Length, "file", "clip.mp4") { Headers = new HeaderDictionary(), ContentType = "video/mp4" }; var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendVideo( token, new BotSendAttachmentRequest { ChatId = chat.Id, Text = "clip", File = file }, CancellationToken.None); var message = ControllerTestFixture.ValueOf(response); Assert.Equal("clip", message.Text); var attachment = Assert.Single(message.Attachments); Assert.Equal(AttachmentKind.File, attachment.Kind); Assert.Equal("video/mp4", attachment.ContentType); Assert.Equal("clip.mp4", attachment.FileName); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendMediaGroup_SavesImageAndVideoAlbum() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("albumbot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var chat = CreateDirectChat(owner, botUser); dbContext.AddRange(owner, botUser, bot, chat); await dbContext.SaveChangesAsync(); var photo = CreateFormFile([0xFF, 0xD8, 0xFF, 0xD9], "photo.jpg", "image/jpeg"); var video = CreateFormFile([0, 0, 0, 24, 102, 116, 121, 112], "clip.mp4", "video/mp4"); var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendMediaGroup( token, new BotSendMediaGroupRequest { ChatId = chat.Id, Text = "album", Files = [photo, video] }, CancellationToken.None); var message = ControllerTestFixture.ValueOf(response); Assert.Equal("album", message.Text); Assert.NotNull(message.MediaAlbumId); Assert.Equal(2, message.Attachments.Count); Assert.Equal("photo.jpg", message.Attachments[0].FileName); Assert.Equal(0, message.Attachments[0].SortOrder); Assert.Equal("clip.mp4", message.Attachments[1].FileName); Assert.Equal(1, message.Attachments[1].SortOrder); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendMediaGroup_RejectsNonMediaAttachment() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("albumbot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var chat = CreateDirectChat(owner, botUser); dbContext.AddRange(owner, botUser, bot, chat); await dbContext.SaveChangesAsync(); var photo = CreateFormFile([0xFF, 0xD8, 0xFF, 0xD9], "photo.jpg", "image/jpeg"); var document = CreateFormFile([0x25, 0x50, 0x44, 0x46], "manual.pdf", "application/pdf"); var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendMediaGroup( token, new BotSendMediaGroupRequest { ChatId = chat.Id, Files = [photo, document] }, CancellationToken.None); var badRequest = Assert.IsType(response.Result); Assert.Equal("sendMediaGroup accepts only image and video files.", badRequest.Value); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendVoice_SavesOctetStreamOggNameAsVoiceNoteAttachment() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("voicebot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var chat = CreateDirectChat(owner, botUser); dbContext.AddRange(owner, botUser, bot, chat); await dbContext.SaveChangesAsync(); await using var stream = new MemoryStream([1, 2, 3, 4, 5]); var file = new FormFile(stream, 0, stream.Length, "file", "Правила ogg") { Headers = new HeaderDictionary(), ContentType = "application/octet-stream" }; var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendVoice( token, new BotSendAttachmentRequest { ChatId = chat.Id, Text = "", File = file }, CancellationToken.None); var message = ControllerTestFixture.ValueOf(response); var attachment = Assert.Single(message.Attachments); Assert.Equal(AttachmentKind.VoiceNote, attachment.Kind); Assert.Equal("audio/ogg", attachment.ContentType); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendMessage_ChannelWithLinkedDiscussion_CreatesDiscussionRoot() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("newsbot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var group = CreateGroup(owner, botUser, canBotPost: true); var channel = CreateChannel(owner, botUser, group); dbContext.AddRange(owner, botUser, bot, group, channel); await dbContext.SaveChangesAsync(); var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendMessage( token, new BotSendMessageRequest(channel.Id, "Bot channel post"), CancellationToken.None); var postDto = ControllerTestFixture.ValueOf(response); Assert.NotNull(postDto.DiscussionMessageId); var discussionRoot = dbContext.Messages.Single(x => x.Id == postDto.DiscussionMessageId); Assert.Equal(group.Id, discussionRoot.ChatId); Assert.Equal("Bot channel post", discussionRoot.Text); Assert.Equal(channel.Title, discussionRoot.ForwardedFromDisplayName); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendVoice_ChannelWithLinkedDiscussion_MirrorsVoiceAttachmentToDiscussionRoot() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("voicebot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var group = CreateGroup(owner, botUser, canBotPost: true); var channel = CreateChannel(owner, botUser, group); dbContext.AddRange(owner, botUser, bot, group, channel); await dbContext.SaveChangesAsync(); var file = CreateFormFile([1, 2, 3, 4], "rules.ogg", "application/octet-stream"); var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendVoice( token, new BotSendAttachmentRequest { ChatId = channel.Id, Text = "Rules", File = file }, 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("Rules", discussionRoot.Text); Assert.Equal(AttachmentKind.VoiceNote, mirroredAttachment.Kind); Assert.Equal("audio/ogg", mirroredAttachment.ContentType); Assert.Equal("rules.ogg", mirroredAttachment.OriginalFileName); Assert.True(File.Exists(mirroredAttachment.StoragePath)); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } [Fact] public async Task SendMessage_DeniesRestrictedGroupBot() { var tempRoot = Path.Combine(Path.GetTempPath(), $"ikar-bot-api-tests-{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 botUser = CreateUser("restrictedbot", isBot: true); var tokenService = new BotTokenService(); var token = "bot-token"; var bot = new Bot { Id = Guid.NewGuid(), UserId = botUser.Id, User = botUser, OwnerUserId = owner.Id, OwnerUser = owner, TokenHash = tokenService.HashToken(token) }; var group = CreateGroup(owner, botUser, canBotPost: false); dbContext.AddRange(owner, botUser, bot, group); await dbContext.SaveChangesAsync(); var controller = CreateController(dbContext, tokenService, tempRoot); var response = await controller.SendMessage( token, new BotSendMessageRequest(group.Id, "test"), CancellationToken.None); var forbidden = Assert.IsType(response.Result); Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); } finally { if (Directory.Exists(tempRoot)) { Directory.Delete(tempRoot, recursive: true); } } } private static BotApiController CreateController( IkarDbContext dbContext, BotTokenService tokenService, string contentRootPath) => new( dbContext, new PresenceTracker(), tokenService, new BotUpdateService(dbContext), new NoopHubContext(), new PushDispatchQueue(), new AttachmentStorageService(new TestWebHostEnvironment(contentRootPath))); private static FormFile CreateFormFile(byte[] content, string fileName, string contentType) { var stream = new MemoryStream(content); return new FormFile(stream, 0, stream.Length, "file", fileName) { Headers = new HeaderDictionary(), ContentType = contentType }; } 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, User botUser, Chat linkedDiscussionChat) { var channel = new Chat { Id = Guid.NewGuid(), Type = ChatType.Channel, Title = "News", CreatedById = owner.Id, CreatedBy = owner, LinkedDiscussionChatId = linkedDiscussionChat.Id, CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero) }; channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = owner.Id, User = owner, IsOwner = true, Role = ChatMemberRole.Owner, CanPostMessages = true, CanDeleteMessages = true, CanInviteUsers = true, CanPinMessages = true }); channel.Members.Add(new ChatMember { ChatId = channel.Id, Chat = channel, UserId = botUser.Id, User = botUser, Role = ChatMemberRole.Admin, CanPostMessages = true }); return channel; } private static Chat CreateDirectChat(User first, User second) { var chat = new Chat { Id = Guid.NewGuid(), Type = ChatType.Direct, CreatedById = first.Id, CreatedBy = first, CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero) }; chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = first.Id, User = first }); chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = second.Id, User = second }); return chat; } private static Chat CreateGroup(User owner, User botUser, bool canBotPost) { var chat = new Chat { Id = Guid.NewGuid(), Type = ChatType.Group, Title = "Group", CreatedById = owner.Id, CreatedBy = owner, CreatedAt = new DateTimeOffset(2026, 5, 21, 12, 0, 0, TimeSpan.Zero) }; chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = owner.Id, User = owner, IsOwner = true, Role = ChatMemberRole.Owner, CanPostMessages = true, CanDeleteMessages = true, CanInviteUsers = true, CanPinMessages = true }); chat.Members.Add(new ChatMember { ChatId = chat.Id, Chat = chat, UserId = botUser.Id, User = botUser, Role = ChatMemberRole.Member, CanPostMessages = canBotPost }); return chat; } private sealed class TestWebHostEnvironment(string contentRootPath) : IWebHostEnvironment { public string ApplicationName { get; set; } = "Ikar.Server.Tests"; public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); public string WebRootPath { get; set; } = contentRootPath; public string EnvironmentName { get; set; } = "Development"; public string ContentRootPath { get; set; } = contentRootPath; public IFileProvider ContentRootFileProvider { get; set; } = new PhysicalFileProvider(contentRootPath); } }