Expand Ikar messaging and Android release support

This commit is contained in:
Курнат Андрей
2026-06-20 19:52:33 +03:00
parent 22e19cdfdb
commit e18f5f5ad6
126 changed files with 15172 additions and 2206 deletions
@@ -0,0 +1,790 @@
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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.BadRequestObjectResult>(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<Microsoft.AspNetCore.Mvc.ObjectResult>(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<MessengerHub>(),
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);
}
}