diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..68d26a1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,193 @@ +[*.cs] + +# SA1101: Prefix local calls with this +dotnet_diagnostic.SA1101.severity = none +[*.cs] +#### Стили именования #### + +# Правила именования + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Спецификации символов + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Стили именования + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case +csharp_indent_labels = one_less_than_current +csharp_style_throw_expression = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_prefer_implicitly_typed_lambda_expression = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_space_around_binary_operators = before_and_after +dotnet_diagnostic.SA1200.severity = none + +# CA1848: Использовать делегаты LoggerMessage +dotnet_diagnostic.CA1848.severity = none +dotnet_diagnostic.SA1010.severity = none +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +csharp_style_conditional_delegate_call = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_elsewhere = false:silent +csharp_style_var_when_type_is_apparent = false:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = block_scoped:silent +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_top_level_statements = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_prefer_system_threading_lock = true:suggestion +csharp_using_directive_placement = outside_namespace:silent +csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_static_anonymous_function = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent + +[*.vb] +#### Стили именования #### + +# Правила именования + +dotnet_naming_rule.interface_should_be_начинается_с_i.severity = suggestion +dotnet_naming_rule.interface_should_be_начинается_с_i.symbols = interface +dotnet_naming_rule.interface_should_be_начинается_с_i.style = начинается_с_i + +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.severity = suggestion +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.symbols = типы +dotnet_naming_rule.типы_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы + +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.severity = suggestion +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.symbols = не_являющиеся_полем_члены +dotnet_naming_rule.не_являющиеся_полем_члены_should_be_всечастиспрописнойбуквы.style = всечастиспрописнойбуквы + +# Спецификации символов + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.типы.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.типы.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.типы.required_modifiers = + +dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_kinds = property, event, method +dotnet_naming_symbols.не_являющиеся_полем_члены.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected +dotnet_naming_symbols.не_являющиеся_полем_члены.required_modifiers = + +# Стили именования + +dotnet_naming_style.начинается_с_i.required_prefix = I +dotnet_naming_style.начинается_с_i.required_suffix = +dotnet_naming_style.начинается_с_i.word_separator = +dotnet_naming_style.начинается_с_i.capitalization = pascal_case + +dotnet_naming_style.всечастиспрописнойбуквы.required_prefix = +dotnet_naming_style.всечастиспрописнойбуквы.required_suffix = +dotnet_naming_style.всечастиспрописнойбуквы.word_separator = +dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case + +dotnet_naming_style.всечастиспрописнойбуквы.required_prefix = +dotnet_naming_style.всечастиспрописнойбуквы.required_suffix = +dotnet_naming_style.всечастиспрописнойбуквы.word_separator = +dotnet_naming_style.всечастиспрописнойбуквы.capitalization = pascal_case + +[*.{cs,vb}] +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_namespace_match_folder = true:suggestion +end_of_line = crlf +dotnet_code_quality_unused_parameters = all:suggestion +dotnet_style_predefined_type_for_member_access = true:silent +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_event = false:silent +dotnet_style_allow_multiple_blank_lines_experimental = true:silent +dotnet_style_allow_statement_immediately_after_block_experimental = true:silent +dotnet_style_readonly_field = true:suggestion +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent \ No newline at end of file diff --git a/.gitignore b/.gitignore index 35063fc..3c1bc91 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,40 @@ CodeCoverage/ # NUnit *.VisualState.xml TestResult.xml -nunit-*.xml \ No newline at end of file +nunit-*.xml + +# Секреты +appsettings.Development.json +appsettings.Production.json +.env +*.env +secrets.json + +# IDE файлы +.vscode/ +.idea/ +*.swp +*.swo + +# Временные файлы +bin/ +obj/ +.vs/ +*.suo +*.user + +# Docker +docker-compose.override.yml + +# Логи +*.log +logs/ + +# Бинарные файлы +*.dll +*.exe +*.pdb + +# БД +*.db +__pycache__/ diff --git a/Bot/Bot.csproj b/Bot/Bot.csproj new file mode 100755 index 0000000..0962ab4 --- /dev/null +++ b/Bot/Bot.csproj @@ -0,0 +1,31 @@ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + Always + + + + + Exe + net9.0 + enable + enable + 2579afaa-0ecc-4aa5-97ea-147ad5b1439f + + + diff --git a/Bot/Hadlers/AdminHandler.cs b/Bot/Hadlers/AdminHandler.cs new file mode 100755 index 0000000..35fcbc4 --- /dev/null +++ b/Bot/Hadlers/AdminHandler.cs @@ -0,0 +1,201 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Services; +using Microsoft.Extensions.Logging; +using Services; +using Telegram.Bot; +using Telegram.Bot.Types; +using static Bot.Services.AdminStateService; + +namespace Bot.Handlers; + +/// +/// Handles administrative operations for goal management including creation and validation. +/// +public class AdminHandler +{ + private readonly ILogger logger; + private readonly IGoalService goalService; + private readonly AdminStateService adminStateService; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + /// Service for goal-related operations. + /// Service for managing admin state. + public AdminHandler( + ILogger logger, + IGoalService goalService, + AdminStateService adminStateService) + { + this.logger = logger; + this.goalService = goalService; + this.adminStateService = adminStateService; + + this.logger.LogDebug("AdminHandler initialized successfully"); + } + + /// + /// Processes goal creation workflow for administrators. + /// + /// Telegram bot client instance. + /// Incoming message from user. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public virtual async Task HandleAdminGoalCreationAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken) + { + if (message.From?.Id == null) + { + logger.LogWarning("Received message with null user ID"); + return; + } + + var userId = message.From.Id; + var chatId = message.Chat.Id; + var state = adminStateService.GetState(userId); + + if (state == null) + { + logger.LogWarning("No admin state found for user {UserId}", userId); + return; + } + + if (string.IsNullOrEmpty(message.Text)) + { + logger.LogWarning("Received empty message text from user {UserId}", userId); + return; + } + + try + { + switch (state.CurrentStep) + { + case AdminStateService.AdminGoalStep.WaitingForTitle: + await ProcessTitleStepAsync(botClient, userId, chatId, message.Text, cancellationToken); + break; + + case AdminStateService.AdminGoalStep.WaitingForDescription: + await ProcessDescriptionStepAsync(botClient, userId, chatId, message.Text, cancellationToken); + break; + + case AdminStateService.AdminGoalStep.WaitingForAmount: + await ProcessAmountStepAsync(botClient, userId, chatId, message.Text, state, cancellationToken); + break; + + default: + logger.LogWarning("Unknown admin step {CurrentStep} for user {UserId}", state.CurrentStep, userId); + break; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing admin goal creation for user {UserId} at step {CurrentStep}", userId, state.CurrentStep); + await SendErrorMessageAsync(botClient, chatId, cancellationToken); + } + } + + /// + /// Initiates the goal creation process for administrators. + /// + /// Telegram bot client instance. + /// Chat identifier. + /// User identifier. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public virtual async Task StartGoalCreationAsync(ITelegramBotClient botClient, long chatId, long userId, CancellationToken cancellationToken) + { + logger.LogInformation("Starting goal creation process for admin user {UserId}", userId); + + try + { + adminStateService.StartGoalCreation(userId, chatId); + await botClient.SendMessage(chatId, "🎯 Введите название новой цели:", cancellationToken: cancellationToken); + + logger.LogDebug("Goal creation started successfully for user {UserId}", userId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start goal creation for user {UserId}", userId); + throw; + } + } + + /// + /// Handles unauthorized admin access attempts. + /// + /// Telegram bot client instance. + /// Chat identifier. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public virtual async Task HandleNotAdmin(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + logger.LogWarning("Non-admin access attempt detected for chat {ChatId}", chatId); + + await botClient.SendMessage(chatId, "❌ Вы не являетесь админом", cancellationToken: cancellationToken); + } + + private async Task ProcessTitleStepAsync(ITelegramBotClient botClient, long userId, long chatId, string title, CancellationToken cancellationToken) + { + if (title.Length >= 255) + { + logger.LogWarning("User {UserId} provided title that exceeds length limit: {TitleLength}", userId, title.Length); + + adminStateService.CancelGoalCreation(userId); + await botClient.SendMessage(chatId, "❌ Слишком длинное название цели \n Попробуйте создать цель заново:", cancellationToken: cancellationToken); + return; + } + + logger.LogDebug("Setting title for user {UserId}", userId); + adminStateService.SetTitle(userId, title); + await botClient.SendMessage(chatId, "📝 Введите описание цели:", cancellationToken: cancellationToken); + } + + private async Task ProcessDescriptionStepAsync(ITelegramBotClient botClient, long userId, long chatId, string description, CancellationToken cancellationToken) + { + logger.LogDebug("Setting description for user {UserId}", userId); + + adminStateService.SetDescription(userId, description); + await botClient.SendMessage(chatId, "💰 Введите целевую сумму в рублях:", cancellationToken: cancellationToken); + } + + private async Task ProcessAmountStepAsync(ITelegramBotClient botClient, long userId, long chatId, string amountText, AdminGoalCreationState state, CancellationToken cancellationToken) + { + if (!decimal.TryParse(amountText, out decimal amount) || amount <= 0 || amount >= 100000000) + { + logger.LogWarning("User {UserId} provided invalid amount: {AmountText}", userId, amountText); + + adminStateService.CancelGoalCreation(userId); + await botClient.SendMessage(chatId, "❌ Введите корректную сумму до 99999999 \n Попробуйте создать цель заново:", cancellationToken: cancellationToken); + return; + } + + try + { + logger.LogInformation("Creating goal for user {UserId} with amount {Amount}", userId, amount); + + var goal = await goalService.CreateGoalAsync(state.Title!, state.Description!, amount); + adminStateService.CancelGoalCreation(userId); + + await botClient.SendMessage( + chatId: chatId, + text: $"✅ Цель создана!\n🎯 {goal.Title}\n💫 Описание: {goal.Description} \n💰 Сумма: {amount}₽", + cancellationToken: cancellationToken); + + logger.LogInformation("Goal created successfully with ID {GoalId} for user {UserId}", goal.Id, userId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create goal in database for user {UserId}", userId); + await SendErrorMessageAsync(botClient, chatId, cancellationToken); + } + } + + private async Task SendErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) => + await botClient.SendMessage( + chatId, + "❌ Произошла ошибка при создании цели. Пожалуйста, попробуйте позже.", + cancellationToken: cancellationToken); +} \ No newline at end of file diff --git a/Bot/Hadlers/CallbackQueryHandler.cs b/Bot/Hadlers/CallbackQueryHandler.cs new file mode 100755 index 0000000..d90d9e3 --- /dev/null +++ b/Bot/Hadlers/CallbackQueryHandler.cs @@ -0,0 +1,182 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Services; +using Microsoft.Extensions.Logging; +using Services; +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; + +namespace Bot.Handlers; + +/// +/// Handles callback queries from inline keyboards in Telegram bot. +/// +public class CallbackQueryHandler : IUpdateHandlerCommand +{ + private readonly ILogger logger; + private readonly PaymentHandler paymentHandler; + private readonly CommandHandler commandHandler; + private readonly UserStateService userStateService; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + /// Handler for payment-related operations. + /// Handler for command processing. + /// Service for managing user state. + public CallbackQueryHandler( + ILogger logger, + PaymentHandler paymentHandler, + CommandHandler commandHandler, + UserStateService userStateService) + { + this.logger = logger; + this.paymentHandler = paymentHandler; + this.commandHandler = commandHandler; + this.userStateService = userStateService; + + this.logger.LogDebug("CallbackQueryHandler initialized successfully"); + } + + /// + /// Determines whether this handler can process the update. + /// + /// The update to check. + /// True if the update contains a callback query; otherwise, false. + public bool CanHandle(Update update) => update.CallbackQuery != null; + + /// + /// Processes callback query updates from inline keyboards. + /// + /// Telegram bot client instance. + /// The update containing callback query. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public async Task HandleAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) + { + var callbackQuery = update.CallbackQuery!; + logger.LogInformation("Received inline keyboard callback from user: {UserId}", callbackQuery.From.Id); + + if (callbackQuery.Message?.Chat == null) + { + logger.LogWarning("Callback query from user {UserId} has no chat information", callbackQuery.From.Id); + return; + } + + try + { + var callbackData = callbackQuery.Data; + var chatId = callbackQuery.Message.Chat.Id; + var userId = callbackQuery.From.Id; + + if (string.IsNullOrEmpty(callbackData)) + { + logger.LogWarning("Empty callback data received from user {UserId}", userId); + await botClient.AnswerCallbackQuery(callbackQuery.Id, "❌ Пустая команда", cancellationToken: cancellationToken); + return; + } + + await ProcessCallbackDataAsync(botClient, callbackQuery, callbackData, chatId, userId, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling callback query from user {UserId}", callbackQuery.From.Id); + await SafeAnswerCallbackQueryAsync(botClient, callbackQuery.Id, "❌ Произошла ошибка", cancellationToken); + } + } + + private async Task ProcessCallbackDataAsync(ITelegramBotClient botClient, CallbackQuery callbackQuery, string callbackData, long chatId, long userId, CancellationToken cancellationToken) + { + switch (callbackData) + { + case "enter_custom_amount": + await HandleCustomAmountAsync(botClient, callbackQuery, chatId, userId, cancellationToken); + break; + + case "donate_100": + case "donate_500": + case "donate_1000": + case "donate_5000": + await HandlePredefinedDonationAsync(botClient, callbackQuery, callbackData, chatId, userId, cancellationToken); + break; + + case "show_stats": + await HandleShowStatsAsync(botClient, callbackQuery, chatId, cancellationToken); + break; + + default: + logger.LogWarning("Unknown callback data: {CallbackData} from user {UserId}", callbackData, userId); + await botClient.AnswerCallbackQuery(callbackQuery.Id, $"❌ Неизвестная команда: {callbackData}", cancellationToken: cancellationToken); + break; + } + } + + private async Task HandleCustomAmountAsync(ITelegramBotClient botClient, CallbackQuery callbackQuery, long chatId, long userId, CancellationToken cancellationToken) + { + logger.LogDebug("User {UserId} selected custom amount donation", userId); + + userStateService.SetWaitingForAmount(userId, chatId); + await botClient.AnswerCallbackQuery(callbackQuery.Id, "Введите сумму пожертвования в рублях", cancellationToken: cancellationToken); + await botClient.SendMessage(chatId, "💎 Введите сумму пожертвования в рублях:", cancellationToken: cancellationToken); + + logger.LogDebug("Custom amount prompt sent to user {UserId}", userId); + } + + private async Task HandlePredefinedDonationAsync(ITelegramBotClient botClient, CallbackQuery callbackQuery, string callbackData, long chatId, long userId, CancellationToken cancellationToken) + { + try + { + var amount = int.Parse(callbackData.Split('_')[1]); + logger.LogInformation("User {UserId} selected predefined donation amount: {Amount}", userId, amount); + + await paymentHandler.CreateDonationInvoice(botClient, chatId, userId, amount, cancellationToken); + await botClient.AnswerCallbackQuery(callbackQuery.Id, cancellationToken: cancellationToken); + + logger.LogDebug("Donation invoice created for user {UserId} with amount {Amount}", userId, amount); + } + catch (FormatException ex) + { + logger.LogError(ex, "Failed to parse donation amount from callback data: {CallbackData}", callbackData); + await botClient.AnswerCallbackQuery(callbackQuery.Id, "❌ Ошибка обработки суммы", cancellationToken: cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create donation invoice for user {UserId}", userId); + await botClient.AnswerCallbackQuery(callbackQuery.Id, "❌ Ошибка создания платежа", cancellationToken: cancellationToken); + } + } + + private async Task HandleShowStatsAsync(ITelegramBotClient botClient, CallbackQuery callbackQuery, long chatId, CancellationToken cancellationToken) + { + logger.LogDebug("User {UserId} requested statistics", callbackQuery.From.Id); + + try + { + await commandHandler.HandleStatsCommand(botClient, chatId, cancellationToken); + await botClient.AnswerCallbackQuery(callbackQuery.Id, cancellationToken: cancellationToken); + + logger.LogDebug("Statistics sent to user {UserId}", callbackQuery.From.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to show statistics for user {UserId}", callbackQuery.From.Id); + await botClient.AnswerCallbackQuery(callbackQuery.Id, "❌ Ошибка загрузки статистики", cancellationToken: cancellationToken); + } + } + + private async Task SafeAnswerCallbackQueryAsync(ITelegramBotClient botClient, string callbackQueryId, string message, CancellationToken cancellationToken) + { + try + { + await botClient.AnswerCallbackQuery(callbackQueryId, message, cancellationToken: cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to answer callback query {CallbackQueryId}", callbackQueryId); + } + } +} \ No newline at end of file diff --git a/Bot/Hadlers/CommandHandler.cs b/Bot/Hadlers/CommandHandler.cs new file mode 100755 index 0000000..ca666ca --- /dev/null +++ b/Bot/Hadlers/CommandHandler.cs @@ -0,0 +1,272 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Services; +using Data.Models; +using Microsoft.Extensions.Logging; +using Services; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace Bot.Handlers; + +/// +/// Handles bot commands and routes them to appropriate handlers. +/// +public class CommandHandler +{ + private readonly ILogger logger; + private readonly IGoalService goalService; + private readonly KeyboardService keyboardService; + private readonly AdminHandler adminHandler; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + /// Service for goal-related operations. + /// Service for keyboard management. + /// Handler for administrative operations. + public CommandHandler( + ILogger logger, + IGoalService goalService, + KeyboardService keyboardService, + AdminHandler adminHandler) + { + this.logger = logger; + this.goalService = goalService; + this.keyboardService = keyboardService; + this.adminHandler = adminHandler; + + this.logger.LogDebug("CommandHandler initialized successfully"); + } + + /// + /// Processes and routes bot commands to appropriate handlers. + /// + /// Telegram bot client instance. + /// The message containing the command. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public virtual async Task HandleCommandAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken) + { + if (message.From?.Id == null) + { + logger.LogWarning("Received command from message with null user"); + return; + } + + if (string.IsNullOrEmpty(message.Text)) + { + logger.LogWarning("Received empty message text from user {UserId}", message.From.Id); + return; + } + + var chatId = message.Chat.Id; + var userId = message.From.Id; + var messageText = message.Text; + + logger.LogInformation("Processing command: {Command} from user {UserId}", messageText, userId); + + try + { + switch (messageText) + { + case "/start": + case "🔄 Обновить": + case "Обновить": + await HandleStartCommandAsync(botClient, message, cancellationToken); + break; + case "/donate": + case "💳 Пожертвовать": + case "Пожертвовать": + await HandleDonateCommandAsync(botClient, chatId, cancellationToken); + break; + case "/stats": + case "📊 Статистика": + case "Статистика": + await HandleStatsCommand(botClient, chatId, cancellationToken); + break; + case "/addgoal": + case "📝 Создать новую цель": + case "Создать новую цель": + await HandleAddGoalCommandAsync(botClient, chatId, userId, cancellationToken); + break; + default: + await HandleUnknownCommandAsync(botClient, message, cancellationToken); + break; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing command {Command} from user {UserId}", messageText, userId); + await SendErrorMessageAsync(botClient, chatId, cancellationToken); + } + } + + /// + /// Handles statistics command requests. + /// + /// Telegram bot client instance. + /// Chat identifier. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public virtual async Task HandleStatsCommand(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + logger.LogInformation("Processing stats command for chat {ChatId}", chatId); + + try + { + var stats = await goalService.GetGoalStatsAsync(); + await botClient.SendMessage( + chatId: chatId, + text: stats, + parseMode: ParseMode.Markdown, + cancellationToken: cancellationToken); + + logger.LogDebug("Statistics sent successfully to chat {ChatId}", chatId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error getting stats for chat {ChatId}", chatId); + await SendErrorMessageAsync(botClient, chatId, cancellationToken); + } + } + + private async Task HandleStartCommandAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken) + { + var chatId = message.Chat.Id; + var userId = message.From.Id; + + logger.LogDebug("Processing start command for user {UserId}", userId); + + try + { + var stats = await goalService.GetStartStats(); + var conclusion = $"🙏 Добро пожаловать в бота для сбора пожертвований! \n{stats} \n\nВыберите действие:"; + + var isAdmin = await goalService.IsUserAdminAsync(userId); + logger.LogDebug("User {UserId} admin status: {IsAdmin}", userId, isAdmin); + + var keyboard = isAdmin + ? keyboardService.GetMainMenuKeyboardForAdmin() + : keyboardService.GetMainMenuKeyboard(); + + await botClient.SendMessage( + chatId: chatId, + text: conclusion, + parseMode: ParseMode.Markdown, + replyMarkup: keyboard, + cancellationToken: cancellationToken); + + logger.LogDebug("Start command processed successfully for user {UserId}", userId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing start command for user {UserId}", userId); + await SendErrorMessageAsync(botClient, chatId, cancellationToken); + } + } + + private async Task HandleDonateCommandAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + logger.LogDebug("Processing donate command for chat {ChatId}", chatId); + + try + { + var goal = await goalService.GetActiveGoalAsync(); + if (goal == null) + { + logger.LogWarning("No active goal found for donate command in chat {ChatId}", chatId); + await botClient.SendMessage( + chatId: chatId, + text: "❌ В данный момент нет активных целей для пожертвований.", + cancellationToken: cancellationToken); + return; + } + + var keyboard = keyboardService.GetDonationAmountKeyboard(); + await botClient.SendMessage( + chatId: chatId, + text: $"💝 **Пожертвование: {goal.Title}** \n\nВыберите сумму пожертвования или введите свою:", + parseMode: ParseMode.Markdown, + replyMarkup: keyboard, + cancellationToken: cancellationToken); + + logger.LogDebug("Donate command processed successfully for chat {ChatId} with goal {GoalTitle}", chatId, goal.Title); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing donate command for chat {ChatId}", chatId); + await SendErrorMessageAsync(botClient, chatId, cancellationToken); + } + } + + private async Task HandleAddGoalCommandAsync(ITelegramBotClient botClient, long chatId, long userId, CancellationToken cancellationToken) + { + logger.LogDebug("Processing add goal command for user {UserId}", userId); + + try + { + if (await goalService.IsUserAdminAsync(userId)) + { + logger.LogInformation("Admin user {UserId} starting goal creation", userId); + await adminHandler.StartGoalCreationAsync(botClient, chatId, userId, cancellationToken); + } + else + { + logger.LogWarning("Non-admin user {UserId} attempted to create goal", userId); + await adminHandler.HandleNotAdmin(botClient, chatId, cancellationToken); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing add goal command for user {UserId}", userId); + await SendErrorMessageAsync(botClient, chatId, cancellationToken); + } + } + + private async Task HandleUnknownCommandAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken) + { + var chatId = message.Chat.Id; + var userId = message.From.Id; + + logger.LogWarning("Unknown command from user {UserId}: {CommandText}", userId, message.Text); + + try + { + var isAdmin = await goalService.IsUserAdminAsync(userId); + var responseText = isAdmin + ? "🤔 Неизвестная команда \n\n Доступные команды: \n• Нажмите 📊 Статистика для просмотра прогресса \n• Нажмите 📝 Создать новую цель" + : "🤔 Неизвестная команда \n\n Доступные команды: \n• Нажмите 📊 Статистика для просмотра прогресса \n• Нажмите 💳 Пожертвовать для помощи проекту"; + + await botClient.SendMessage( + chatId: chatId, + text: responseText, + cancellationToken: cancellationToken); + + logger.LogDebug("Unknown command response sent to user {UserId}", userId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending unknown command response to user {UserId}", userId); + } + } + + private async Task SendErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + try + { + await botClient.SendMessage( + chatId: chatId, + text: "❌ Не удалось выполнить команду. Попробуйте позже.", + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to send error message to chat {ChatId}", chatId); + } + } +} \ No newline at end of file diff --git a/Bot/Hadlers/IUpdateHandlerCommand.cs b/Bot/Hadlers/IUpdateHandlerCommand.cs new file mode 100755 index 0000000..5b9abf8 --- /dev/null +++ b/Bot/Hadlers/IUpdateHandlerCommand.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Telegram.Bot; +using Telegram.Bot.Types; + +namespace Bot.Handlers; + +/// +/// Defines the contract for Telegram bot update command handlers. +/// +public interface IUpdateHandlerCommand +{ + /// + /// Asynchronously processes an incoming update from Telegram Bot. + /// + /// Telegram Bot client for API interactions. + /// The update containing data to process. + /// Cancellation token for the asynchronous operation. + /// A task representing the asynchronous processing operation. + /// + /// Implementations should only handle updates for which the method returns true. + /// In case of errors, it's recommended to log exceptions and handle them appropriately. + /// + Task HandleAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken); + + /// + /// Determines whether this handler can process the specified update. + /// + /// The update to check. + /// + /// true if this handler can process the update; otherwise, false. + /// + /// + /// This method should be fast and not perform heavy operations. + /// Typically checks the update type or presence of specific data. + /// + bool CanHandle(Update update); +} \ No newline at end of file diff --git a/Bot/Hadlers/MessageHandler.cs b/Bot/Hadlers/MessageHandler.cs new file mode 100755 index 0000000..ec0afc0 --- /dev/null +++ b/Bot/Hadlers/MessageHandler.cs @@ -0,0 +1,187 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Services; +using Data.Models; +using Microsoft.Extensions.Logging; +using Services; +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace Bot.Handlers; + +/// +/// Handles incoming messages and routes them to appropriate processors based on message type and user state. +/// +public class MessageHandler : IUpdateHandlerCommand +{ + private readonly ILogger logger; + private readonly IDonationService donationService; + private readonly IGoalService goalService; + private readonly CommandHandler commandHandler; + private readonly PaymentHandler paymentHandler; + private readonly UserStateService userStateService; + private readonly AdminHandler adminHandler; + private readonly AdminStateService adminStateService; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + /// Service for donation-related operations. + /// Service for goal-related operations. + /// Handler for command processing. + /// Handler for payment processing. + /// Service for managing user state. + /// Handler for administrative operations. + /// Service for managing admin state. + public MessageHandler( + ILogger logger, + IDonationService donationService, + IGoalService goalService, + CommandHandler commandHandler, + PaymentHandler paymentHandler, + UserStateService userStateService, + AdminHandler adminHandler, + AdminStateService adminStateService) + { + this.logger = logger; + this.donationService = donationService; + this.goalService = goalService; + this.commandHandler = commandHandler; + this.paymentHandler = paymentHandler; + this.userStateService = userStateService; + this.adminHandler = adminHandler; + this.adminStateService = adminStateService; + + this.logger.LogDebug("MessageHandler initialized successfully"); + } + + /// + /// Determines whether this handler can process the update. + /// + /// The update to check. + /// True if the update contains a message; otherwise, false. + public bool CanHandle(Update update) => update.Message != null; + + /// + /// Processes incoming messages and routes them based on message type and user state. + /// + /// Telegram bot client instance. + /// The update containing the message. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public async Task HandleAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) + { + var message = update.Message!; + + if (message.From is null) + { + logger.LogWarning("Received message with null user information"); + return; + } + + var userId = message.From.Id; + var chatId = message.Chat.Id; + + logger.LogDebug("Processing message from user {UserId} in chat {ChatId}", userId, chatId); + + try + { + await RegisterOrUpdateUserAsync(message); + + if (await ProcessSpecialMessageTypesAsync(botClient, message, cancellationToken)) + { + return; + } + + if (await ProcessUserStateBasedMessagesAsync(botClient, message, cancellationToken)) + { + return; + } + + await ProcessTextMessageAsync(botClient, message, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing message from user {UserId}", userId); + } + } + + private async Task RegisterOrUpdateUserAsync(Message message) + { + try + { + await donationService.GetOrCreateUserAsync( + message.From.Id, + message.From.Username, + message.From.FirstName, + message.From.LastName); + + logger.LogDebug("User {UserId} registered/updated successfully", message.From.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to register/update user {UserId}", message.From.Id); + } + } + + private async Task ProcessSpecialMessageTypesAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken) + { + if (message.SuccessfulPayment != null) + { + logger.LogInformation("Processing successful payment from user {UserId}", message.From.Id); + await paymentHandler.HandleSuccessfulPaymentAsync(botClient, message, cancellationToken); + return true; + } + + return false; + } + + private async Task ProcessUserStateBasedMessagesAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken) + { + var userId = message.From.Id; + var chatId = message.Chat.Id; + + if (userStateService.IsWaitingForAmount(userId, chatId)) + { + logger.LogDebug("User {UserId} is in waiting for amount state", userId); + await paymentHandler.HandleCustomAmountInputAsync(botClient, message, cancellationToken); + return true; + } + + var isAdmin = await goalService.IsUserAdminAsync(userId); + if (isAdmin && adminStateService.IsUserCreatingGoal(userId)) + { + logger.LogDebug("Admin user {UserId} is in goal creation state", userId); + await adminHandler.HandleAdminGoalCreationAsync(botClient, message, cancellationToken); + return true; + } + + return false; + } + + private async Task ProcessTextMessageAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(message.Text)) + { + logger.LogDebug("Received non-text message from user {UserId}", message.From.Id); + return; + } + + logger.LogInformation("Processing text message from user {UserId}: {MessageText}", message.From.Id, message.Text); + + try + { + await commandHandler.HandleCommandAsync(botClient, message, cancellationToken); + logger.LogDebug("Text message processed successfully for user {UserId}", message.From.Id); + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing text message from user {UserId}", message.From.Id); + } + } +} \ No newline at end of file diff --git a/Bot/Hadlers/PaymentHandler.cs b/Bot/Hadlers/PaymentHandler.cs new file mode 100755 index 0000000..de91005 --- /dev/null +++ b/Bot/Hadlers/PaymentHandler.cs @@ -0,0 +1,283 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Services; +using Configurations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Services; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.Payments; + +namespace Bot.Handlers; + +/// +/// Handles payment-related operations including donation invoices and payment processing. +/// +public class PaymentHandler +{ + private const int MinimumDonationAmount = 60; + private const int MaximumDonationAmount = 100000; + + private readonly ILogger logger; + private readonly IGoalService goalService; + private readonly IDonationService donationService; + private readonly UserStateService userStateService; + private readonly BotConfig botConfig; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + /// Service for goal-related operations. + /// Service for donation processing. + /// Service for managing user state. + /// Bot configuration options. + public PaymentHandler( + ILogger logger, + IGoalService goalService, + IDonationService donationService, + UserStateService userStateService, + IOptions botConfig) + { + this.logger = logger; + this.goalService = goalService; + this.donationService = donationService; + this.userStateService = userStateService; + this.botConfig = botConfig.Value; + + this.logger.LogDebug("PaymentHandler initialized successfully"); + } + + /// + /// Processes custom donation amount input from users. + /// + /// Telegram bot client instance. + /// The message containing the custom amount. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public virtual async Task HandleCustomAmountInputAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken) + { + if (message.From?.Id == null) + { + logger.LogWarning("Received custom amount input from null user"); + return; + } + + var userId = message.From.Id; + var chatId = message.Chat.Id; + + logger.LogDebug("Processing custom amount input from user {UserId}", userId); + + userStateService.RemoveWaitingForAmount(userId); + + if (string.IsNullOrEmpty(message.Text)) + { + logger.LogWarning("User {UserId} sent empty custom amount", userId); + await SendInvalidAmountMessageAsync(botClient, chatId, cancellationToken); + return; + } + + if (!int.TryParse(message.Text, out int amount) || amount <= 0) + { + logger.LogWarning("User {UserId} sent invalid custom amount: {AmountText}", userId, message.Text); + await SendInvalidAmountMessageAsync(botClient, chatId, cancellationToken); + return; + } + + await CreateDonationInvoice(botClient, chatId, userId, amount, cancellationToken); + } + + /// + /// Creates a donation invoice for the specified amount. + /// + /// Telegram bot client instance. + /// Chat identifier. + /// User identifier. + /// Donation amount in rubles. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public virtual async Task CreateDonationInvoice(ITelegramBotClient botClient, long chatId, long userId, int amountRub, CancellationToken cancellationToken) + { + logger.LogInformation("Creating donation invoice for user {UserId} with amount {AmountRub} RUB", userId, amountRub); + + try + { + var goal = await goalService.GetActiveGoalAsync(); + if (goal == null) + { + logger.LogWarning("No active goal found for donation from user {UserId}", userId); + await botClient.SendMessage(chatId, "❌ В данный момент нет активных целей для пожертвований.", cancellationToken: cancellationToken); + return; + } + + if (!ValidateDonationAmount(amountRub)) + { + logger.LogWarning("User {UserId} provided invalid donation amount: {AmountRub}", userId, amountRub); + await SendAmountValidationMessageAsync(botClient, chatId, cancellationToken); + return; + } + + await SendDonationInvoiceAsync(botClient, chatId, userId, amountRub, goal, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating donation invoice for user {UserId}", userId); + await SendErrorMessageAsync(botClient, chatId, cancellationToken); + } + } + + /// + /// Processes successful payments and updates donation records. + /// + /// Telegram bot client instance. + /// The message containing successful payment information. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public virtual async Task HandleSuccessfulPaymentAsync(ITelegramBotClient botClient, Message message, CancellationToken cancellationToken) + { + if (message.SuccessfulPayment == null || message.From == null) + { + logger.LogWarning("Received successful payment with null payment or user information"); + return; + } + + var payment = message.SuccessfulPayment; + var user = message.From; + var chatId = message.Chat.Id; + + logger.LogInformation("Processing successful payment from user {UserId}, charge ID: {ChargeId}", user.Id, payment.TelegramPaymentChargeId); + + try + { + await DeleteInvoiceMessageAsync(botClient, chatId, message.MessageId); + + var amount = payment.TotalAmount / 100m; + var success = await donationService.ProcessDonationAsync(user.Id, amount, payment.Currency, payment.TelegramPaymentChargeId); + + if (success) + { + await SendThankYouMessageAsync(botClient, chatId, amount, payment.Currency, cancellationToken); + logger.LogInformation("Successfully processed donation from user {UserId}, amount: {Amount} {Currency}", user.Id, amount, payment.Currency); + } + else + { + await SendDonationProcessingErrorMessageAsync(botClient, chatId, cancellationToken); + logger.LogError("Failed to process donation from user {UserId}, charge ID: {ChargeId}", user.Id, payment.TelegramPaymentChargeId); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error handling successful payment from user {UserId}", user.Id); + await SendPaymentProcessingErrorMessageAsync(botClient, chatId, cancellationToken); + } + } + + private bool ValidateDonationAmount(int amountRub) + { + if (amountRub > MaximumDonationAmount) + { + logger.LogWarning("Donation amount {AmountRub} exceeds maximum limit", amountRub); + return false; + } + + if (amountRub < MinimumDonationAmount) + { + logger.LogWarning("Donation amount {AmountRub} below minimum limit", amountRub); + return false; + } + + return true; + } + + private async Task SendDonationInvoiceAsync(ITelegramBotClient botClient, long chatId, long userId, int amountRub, Data.Models.DonationGoal goal, CancellationToken cancellationToken) + { + var amountKopecks = amountRub * 100; + var prices = new[] { new LabeledPrice("Пожертвование", amountKopecks) }; + var payload = $"donation_{goal.Id}_{userId}_{DateTime.UtcNow.Ticks}"; + + if (string.IsNullOrEmpty(botConfig.PaymentProviderToken)) + { + logger.LogError("Payment provider token is not configured"); + throw new InvalidOperationException("Payment provider token is not configured"); + } + + await botClient.SendInvoice( + chatId: chatId, + title: $"Пожертвование: {goal.Title}", + description: $"Пожертвование на сумму {amountRub} руб.", + payload: payload, + providerToken: botConfig.PaymentProviderToken, + currency: "RUB", + prices: prices, + cancellationToken: cancellationToken); + + logger.LogInformation("Donation invoice created for user {UserId}, amount: {AmountRub} RUB, goal: {GoalTitle}", userId, amountRub, goal.Title); + } + + private async Task DeleteInvoiceMessageAsync(ITelegramBotClient botClient, long chatId, int messageId) + { + try + { + await botClient.DeleteMessage(chatId, messageId); + logger.LogDebug("Invoice message {MessageId} deleted successfully", messageId); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Could not delete invoice message {MessageId}", messageId); + } + } + + private async Task SendThankYouMessageAsync(ITelegramBotClient botClient, long chatId, decimal amount, string currency, CancellationToken cancellationToken) + { + var stats = await goalService.GetStartStats(); + await botClient.SendMessage( + chatId: chatId, + text: $"✅ **Спасибо за ваше пожертвование!** \n\n💝 Вы пожертвовали: {amount:N2} {currency} \n{stats} \n\nВаша поддержка очень важна для нас!", + parseMode: ParseMode.Markdown, + cancellationToken: cancellationToken); + } + + private async Task SendInvalidAmountMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + await botClient.SendMessage( + chatId: chatId, + text: "❌ Пожалуйста, введите корректную сумму в рублях (только цифры)", + cancellationToken: cancellationToken); + } + + private async Task SendAmountValidationMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + await botClient.SendMessage( + chatId: chatId, + text: $"❌ Сумма пожертвования должна быть от {MinimumDonationAmount} до {MaximumDonationAmount} руб.", + cancellationToken: cancellationToken); + } + + private async Task SendErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + await botClient.SendMessage( + chatId: chatId, + text: "❌ Произошла ошибка при создании платежа. Попробуйте позже.", + cancellationToken: cancellationToken); + } + + private async Task SendDonationProcessingErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + await botClient.SendMessage( + chatId: chatId, + text: "❌ Произошла ошибка при обработке вашего платежа. Мы уже работаем над этим.", + cancellationToken: cancellationToken); + } + + private async Task SendPaymentProcessingErrorMessageAsync(ITelegramBotClient botClient, long chatId, CancellationToken cancellationToken) + { + await botClient.SendMessage( + chatId: chatId, + text: "❌ Произошла ошибка при обработке платежа. Мы уже работаем над этим.", + cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/Bot/Hadlers/PreCheckoutQueryHandler.cs b/Bot/Hadlers/PreCheckoutQueryHandler.cs new file mode 100755 index 0000000..9c1e37a --- /dev/null +++ b/Bot/Hadlers/PreCheckoutQueryHandler.cs @@ -0,0 +1,126 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Services; +using Microsoft.Extensions.Logging; +using Services; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Payments; + +namespace Bot.Handlers; + +/// +/// Handles pre-checkout queries to validate payment requests before processing. +/// +public class PreCheckoutQueryHandler : IUpdateHandlerCommand +{ + private readonly ILogger logger; + private readonly IGoalService goalService; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + /// Service for goal-related operations. + public PreCheckoutQueryHandler( + ILogger logger, + IGoalService goalService) + { + this.logger = logger; + this.goalService = goalService; + + this.logger.LogDebug("PreCheckoutQueryHandler initialized successfully"); + } + + /// + /// Determines whether this handler can process the update. + /// + /// The update to check. + /// True if the update contains a pre-checkout query; otherwise, false. + public bool CanHandle(Update update) => update.PreCheckoutQuery != null; + + /// + /// Processes pre-checkout queries to validate payment requests. + /// + /// Telegram bot client instance. + /// The update containing the pre-checkout query. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public async Task HandleAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) + { + var preCheckoutQuery = update.PreCheckoutQuery!; + + if (preCheckoutQuery.From == null) + { + logger.LogWarning("Received pre-checkout query with null user information"); + return; + } + + logger.LogInformation( + "Processing pre-checkout query from user {UserId} with payload {InvoicePayload}", preCheckoutQuery.From.Id, preCheckoutQuery.InvoicePayload); + + try + { + var goal = await goalService.GetActiveGoalAsync(); + + if (goal != null) + { + await botClient.AnswerPreCheckoutQuery( + preCheckoutQueryId: preCheckoutQuery.Id, + cancellationToken: cancellationToken); + + logger.LogInformation( + "Approved pre-checkout query for user {UserId}, payload: {InvoicePayload}, amount: {Amount} {Currency}", + preCheckoutQuery.From.Id, + preCheckoutQuery.InvoicePayload, + preCheckoutQuery.TotalAmount / 100m, + preCheckoutQuery.Currency); + } + else + { + logger.LogWarning( + "Rejected pre-checkout query from user {UserId} - no active goal found", + preCheckoutQuery.From.Id); + + await botClient.AnswerPreCheckoutQuery( + preCheckoutQueryId: preCheckoutQuery.Id, + errorMessage: "В данный момент нет активных целей для пожертвований", + cancellationToken: cancellationToken); + } + } + catch (Exception ex) + { + logger.LogError( + ex, + "Error handling pre-checkout query from user {UserId}", + preCheckoutQuery.From.Id); + + await SafeAnswerPreCheckoutQueryAsync( + botClient, + preCheckoutQuery.Id, + "Произошла ошибка при обработке платежа", + cancellationToken); + } + } + + private async Task SafeAnswerPreCheckoutQueryAsync( + ITelegramBotClient botClient, + string preCheckoutQueryId, + string errorMessage, + CancellationToken cancellationToken) + { + try + { + await botClient.AnswerPreCheckoutQuery( + preCheckoutQueryId: preCheckoutQueryId, + errorMessage: errorMessage, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to answer pre-checkout query {PreCheckoutQueryId}", preCheckoutQueryId); + } + } +} \ No newline at end of file diff --git a/Bot/IReceiverService.cs b/Bot/IReceiverService.cs new file mode 100755 index 0000000..e499c19 --- /dev/null +++ b/Bot/IReceiverService.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bot; + +/// +/// Defines a contract for receiving and processing updates in the Telegram bot. +/// +public interface IReceiverService +{ + /// + /// Starts receiving and processing updates from the Telegram bot. + /// + /// Cancellation token to stop the receiving process. + /// A representing the asynchronous operation. + Task ReceiveAsync(CancellationToken stoppingToken); +} \ No newline at end of file diff --git a/Bot/PollingService.cs b/Bot/PollingService.cs new file mode 100755 index 0000000..aa0cc45 --- /dev/null +++ b/Bot/PollingService.cs @@ -0,0 +1,128 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Telegram.Bot; +using Telegram.Bot.Exceptions; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; + +namespace Bot; + +/// +/// Background service for polling Telegram Bot API for updates. +/// +public class PollingService : BackgroundService +{ + private readonly IServiceProvider serviceProvider; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider for dependency injection. + /// Logger instance for tracking operations. + public PollingService(IServiceProvider serviceProvider, ILogger logger) + { + this.serviceProvider = serviceProvider; + this.logger = logger; + } + + /// + /// Triggered when the application host is performing a graceful shutdown. + /// + /// Cancellation token indicating that shutdown should be no longer graceful. + /// A representing the asynchronous operation. + public override async Task StopAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Polling service is stopping gracefully"); + + await base.StopAsync(cancellationToken); + + logger.LogInformation("Polling service stopped gracefully"); + } + + /// + /// Executes the polling service as a background task. + /// + /// Cancellation token to stop the service. + /// A representing the asynchronous operation. + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Polling service is starting"); + + try + { + await RunPollingLoopAsync(stoppingToken); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Polling service encountered a critical error and is stopping"); + } + finally + { + logger.LogInformation("Polling service has stopped"); + } + } + + private async Task RunPollingLoopAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ProcessPollingIterationAsync(stoppingToken); + } + catch (Exception ex) + { + await HandlePollingErrorAsync(ex, stoppingToken); + } + } + } + + private async Task ProcessPollingIterationAsync(CancellationToken stoppingToken) + { + // Create a new scope for each iteration to ensure fresh service instances + using var scope = serviceProvider.CreateScope(); + var receiver = scope.ServiceProvider.GetRequiredService(); + + logger.LogDebug("Starting polling iteration"); + + await receiver.ReceiveAsync(stoppingToken); + + logger.LogDebug("Completed polling iteration successfully"); + } + + private async Task HandlePollingErrorAsync(Exception ex, CancellationToken stoppingToken) + { + logger.LogError(ex, "Polling iteration failed with exception"); + + // Handle specific Telegram API exceptions + switch (ex) + { + case ApiRequestException apiEx: + logger.LogWarning("Telegram API request failed: {ErrorCode} - {Message}", apiEx.ErrorCode, apiEx.Message); + break; + + case RequestException reqEx: + logger.LogWarning("Telegram request failed, possible network issue: {Message}", reqEx.Message); + break; + } + + // Cooldown before retrying after errors + logger.LogInformation("Waiting 5 seconds before retrying after error"); + + try + { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + catch (TaskCanceledException) + { + logger.LogDebug("Cooldown delay was cancelled"); + } + } +} \ No newline at end of file diff --git a/Bot/Program.cs b/Bot/Program.cs new file mode 100755 index 0000000..813ab9b --- /dev/null +++ b/Bot/Program.cs @@ -0,0 +1,132 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot; +using Bot.Handlers; +using Bot.Services; +using Configurations; +using Data; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Services; +using Telegram.Bot; +using Telegram.Bot.Polling; + +internal class Program +{ + private static async Task Main(string[] args) + { + var builder = Host.CreateApplicationBuilder(args); + + // Configure logging + builder.Logging.AddConsole(); + builder.Logging.AddDebug(); + + // Add configuration + builder.Services.Configure(builder.Configuration.GetSection("BotConfig")); + builder.Services.Configure(builder.Configuration.GetSection("DatabaseConfig")); + builder.Configuration.AddUserSecrets(); + + // Validate critical configurations + ValidateConfiguration(builder); + + // Register data layer services + builder.Services.AddScoped(); + + // Register business logic services + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Register update handling infrastructure + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Register command handlers + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Register update handler commands + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // Register state management services + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // Register Telegram Bot Client + builder.Services.AddSingleton(sp => + { + var config = sp.GetRequiredService>().Value; + + if (string.IsNullOrEmpty(config.BotToken)) + { + var logger = sp.GetRequiredService>(); + logger.LogCritical("Bot token is not configured"); + throw new InvalidOperationException("Bot token is not configured"); + } + + var botClient = new TelegramBotClient(config.BotToken); + + var loggerFactory = sp.GetRequiredService(); + var botLogger = loggerFactory.CreateLogger(); + botLogger.LogInformation("Telegram Bot Client initialized successfully"); + + return botClient; + }); + + // Register background service + builder.Services.AddHostedService(); + + var host = builder.Build(); + + host.Run(); + } + + /// + /// Validates critical configuration settings required for application startup. + /// + /// The host application builder. + private static void ValidateConfiguration(HostApplicationBuilder builder) + { + var botConfig = builder.Configuration.GetSection("BotConfig").Get(); + var databaseConfig = builder.Configuration.GetSection("DatabaseConfig").Get(); + + if (botConfig == null) + { + throw new InvalidOperationException("BotConfig section is missing from configuration"); + } + + if (string.IsNullOrEmpty(botConfig.BotToken)) + { + throw new InvalidOperationException("BotToken is not configured in BotConfig"); + } + + if (string.IsNullOrEmpty(botConfig.PaymentProviderToken)) + { + throw new InvalidOperationException("PaymentProviderToken is not configured in BotConfig"); + } + + if (databaseConfig == null) + { + throw new InvalidOperationException("DatabaseConfig section is missing from configuration"); + } + + if (string.IsNullOrEmpty(databaseConfig.ConnectionString)) + { + throw new InvalidOperationException("ConnectionString is not configured in DatabaseConfig"); + } + + // Log validation success (we'll use a temporary logger since we don't have DI yet) + Console.WriteLine("Configuration validation completed successfully"); + } +} \ No newline at end of file diff --git a/Bot/ReceiverService.cs b/Bot/ReceiverService.cs new file mode 100755 index 0000000..b5f936b --- /dev/null +++ b/Bot/ReceiverService.cs @@ -0,0 +1,74 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Microsoft.Extensions.Logging; +using Telegram.Bot; +using Telegram.Bot.Polling; +using Telegram.Bot.Types.Enums; + +namespace Bot; + +/// +/// Receiver service acting as a bridge between polling and update processing. +/// +public class ReceiverService : IReceiverService +{ + private readonly ITelegramBotClient botClient; + private readonly IUpdateHandler updateHandler; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// Telegram bot client instance. + /// Handler for processing updates. + /// Logger instance for tracking operations. + public ReceiverService( + ITelegramBotClient botClient, + IUpdateHandler updateHandler, + ILogger logger) + { + this.botClient = botClient; + this.updateHandler = updateHandler; + this.logger = logger; + + this.logger.LogDebug("ReceiverService initialized"); + } + + /// + /// Starts receiving updates from Telegram Bot API. + /// + /// Cancellation token to stop receiving updates. + /// A representing the asynchronous operation. + public async Task ReceiveAsync(CancellationToken stoppingToken) + { + var receiverOptions = new ReceiverOptions() + { + AllowedUpdates = Array.Empty(), + DropPendingUpdates = true, + }; + + try + { + // Get bot information + var me = await botClient.GetMe(stoppingToken); + logger.LogInformation("Bot @{BotUsername} (ID: {BotId}) is starting to receive updates", me.Username, me.Id); + + // Start receiving updates (blocking call) + logger.LogDebug("Starting to receive updates with configured options"); + + await botClient.ReceiveAsync( + updateHandler: updateHandler, + receiverOptions: receiverOptions, + cancellationToken: stoppingToken); + + logger.LogInformation("Bot @{BotUsername} stopped receiving updates", me.Username); + } + catch (Exception ex) + { + logger.LogError(ex, "Error occurred while receiving updates"); + throw; + } + } +} \ No newline at end of file diff --git a/Bot/Services/AdminGoalStep.cs b/Bot/Services/AdminGoalStep.cs new file mode 100755 index 0000000..fbf88f5 --- /dev/null +++ b/Bot/Services/AdminGoalStep.cs @@ -0,0 +1,34 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Bot.Services; + +public partial class AdminStateService +{ + /// + /// Represents the steps in the admin goal creation workflow. + /// + public enum AdminGoalStep + { + /// + /// No active goal creation. + /// + None, + + /// + /// Waiting for goal title input. + /// + WaitingForTitle, + + /// + /// Waiting for goal description input. + /// + WaitingForDescription, + + /// + /// Waiting for target amount input. + /// + WaitingForAmount, + } +} \ No newline at end of file diff --git a/Bot/Services/AdminStateService.cs b/Bot/Services/AdminStateService.cs new file mode 100755 index 0000000..402e170 --- /dev/null +++ b/Bot/Services/AdminStateService.cs @@ -0,0 +1,194 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Microsoft.Extensions.Logging; + +namespace Bot.Services; + +/// +/// Manages administrative state for goal creation workflow. +/// +public partial class AdminStateService +{ + private readonly ILogger logger; + private readonly Dictionary adminStates = []; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + public AdminStateService(ILogger logger) + { + this.logger = logger; + this.logger.LogDebug("AdminStateService initialized"); + } + + /// + /// Starts the goal creation process for an admin user. + /// + /// The user identifier. + /// The chat identifier. + public virtual void StartGoalCreation(long userId, long chatId) + { + adminStates[userId] = new AdminGoalCreationState + { + ChatId = chatId, + CurrentStep = AdminGoalStep.WaitingForTitle, + }; + + logger.LogInformation("Started goal creation for admin user {UserId} in chat {ChatId}", userId, chatId); + } + + /// + /// Retrieves the current state for an admin user. + /// + /// The user identifier. + /// The admin goal creation state if found; otherwise, null. + public virtual AdminGoalCreationState? GetState(long userId) + { + var stateExists = adminStates.TryGetValue(userId, out var state); + + if (!stateExists) + { + logger.LogDebug("No state found for user {UserId}", userId); + } + + return state; + } + + /// + /// Sets the title for the goal being created and advances to the next step. + /// + /// The user identifier. + /// The goal title. + public virtual void SetTitle(long userId, string title) + { + if (adminStates.TryGetValue(userId, out var state)) + { + state.Title = title; + state.CurrentStep = AdminGoalStep.WaitingForDescription; + + logger.LogDebug("Set title for user {UserId}: {Title}", userId, title); + } + else + { + logger.LogWarning("Attempted to set title for non-existent user state: {UserId}", userId); + } + } + + /// + /// Sets the description for the goal being created and advances to the next step. + /// + /// The user identifier. + /// The goal description. + public virtual void SetDescription(long userId, string description) + { + if (adminStates.TryGetValue(userId, out var state)) + { + state.Description = description; + state.CurrentStep = AdminGoalStep.WaitingForAmount; + + logger.LogDebug("Set description for user {UserId}", userId); + } + else + { + logger.LogWarning("Attempted to set description for non-existent user state: {UserId}", userId); + } + } + + /// + /// Sets the target amount for the goal being created and completes the process. + /// + /// The user identifier. + /// The target amount. + public void SetAmount(long userId, decimal amount) + { + if (adminStates.TryGetValue(userId, out var state)) + { + state.TargetAmount = amount; + state.CurrentStep = AdminGoalStep.None; + + logger.LogDebug("Set amount for user {UserId}: {Amount}", userId, amount); + } + else + { + logger.LogWarning("Attempted to set amount for non-existent user state: {UserId}", userId); + } + } + + /// + /// Cancels the goal creation process for a user. + /// + /// The user identifier. + public virtual void CancelGoalCreation(long userId) + { + var removed = adminStates.Remove(userId); + + if (removed) + { + logger.LogInformation("Canceled goal creation for user {UserId}", userId); + } + else + { + logger.LogDebug("Attempted to cancel non-existent goal creation for user {UserId}", userId); + } + } + + /// + /// Checks if a user is currently in the process of creating a goal. + /// + /// The user identifier. + /// True if the user is creating a goal; otherwise, false. + public virtual bool IsUserCreatingGoal(long userId) + { + var isCreating = adminStates.ContainsKey(userId) && + adminStates[userId].CurrentStep != AdminGoalStep.None; + + logger.LogDebug("User {UserId} goal creation status: {IsCreating}", userId, isCreating); + + return isCreating; + } + + /// + /// Gets the number of active admin states (for monitoring purposes). + /// + /// The count of active admin states. + public int GetActiveStateCount() + { + var count = adminStates.Count; + logger.LogTrace("Current active admin states: {Count}", count); + return count; + } + + /// + /// Represents the state of an admin user during goal creation process. + /// + public class AdminGoalCreationState + { + /// + /// Gets or sets the chat identifier where goal creation is taking place. + /// + public long ChatId { get; set; } + + /// + /// Gets or sets the goal title. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the goal description. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the target amount for the goal. + /// + public decimal? TargetAmount { get; set; } + + /// + /// Gets or sets the current step in the goal creation process. + /// + public AdminGoalStep CurrentStep { get; set; } = AdminGoalStep.None; + } +} \ No newline at end of file diff --git a/Bot/Services/KeyboardService.cs b/Bot/Services/KeyboardService.cs new file mode 100755 index 0000000..f2d91e3 --- /dev/null +++ b/Bot/Services/KeyboardService.cs @@ -0,0 +1,135 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Microsoft.Extensions.Logging; +using Telegram.Bot.Types.ReplyMarkups; + +namespace Bot.Services; + +/// +/// Service for creating and managing Telegram bot keyboards and markup. +/// +public class KeyboardService +{ + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + public KeyboardService(ILogger logger) + { + this.logger = logger; + this.logger.LogDebug("KeyboardService initialized"); + } + + /// + /// Creates the main menu keyboard for regular users. + /// + /// Configured reply keyboard markup for main menu. + public virtual ReplyKeyboardMarkup GetMainMenuKeyboard() + { + logger.LogDebug("Creating main menu keyboard for regular user"); + + return new ReplyKeyboardMarkup( + [ + [new KeyboardButton("📊 Статистика"), new KeyboardButton("💳 Пожертвовать")], + [new KeyboardButton("🔄 Обновить")], + ]) + { + ResizeKeyboard = true, + OneTimeKeyboard = false, + }; + } + + /// + /// Creates the main menu keyboard for admin users with administrative options. + /// + /// Configured reply keyboard markup for admin main menu. + public virtual ReplyKeyboardMarkup GetMainMenuKeyboardForAdmin() + { + logger.LogDebug("Creating main menu keyboard for admin user"); + + return new ReplyKeyboardMarkup( + [ + [new KeyboardButton("📊 Статистика"), new KeyboardButton("📝 Создать новую цель")], + [new KeyboardButton("🔄 Обновить")], + ]) + { + ResizeKeyboard = true, + OneTimeKeyboard = false, + }; + } + + /// + /// Creates an inline keyboard for donation amount selection. + /// + /// Configured inline keyboard markup for donation amounts. + public virtual InlineKeyboardMarkup GetDonationAmountKeyboard() + { + logger.LogDebug("Creating donation amount inline keyboard"); + + try + { + var keyboard = new InlineKeyboardMarkup(new[] + { + new[] + { + InlineKeyboardButton.WithCallbackData("100 ₽", "donate_100"), + InlineKeyboardButton.WithCallbackData("500 ₽", "donate_500"), + }, + new[] + { + InlineKeyboardButton.WithCallbackData("1000 ₽", "donate_1000"), + InlineKeyboardButton.WithCallbackData("5000 ₽", "donate_5000"), + }, + new[] + { + InlineKeyboardButton.WithCallbackData("💎 Другая сумма", "enter_custom_amount"), + }, + }); + + logger.LogDebug("Donation amount keyboard created successfully"); + return keyboard; + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating donation amount keyboard"); + throw; + } + } + + /// + /// Creates a simple inline keyboard with a single back button. + /// + /// The callback data for the back button. + /// Configured inline keyboard markup with back button. + public InlineKeyboardMarkup GetBackButtonKeyboard(string callbackData = "back_to_main") + { + logger.LogDebug("Creating back button keyboard with callback data: {CallbackData}", callbackData); + + return new InlineKeyboardMarkup( + InlineKeyboardButton.WithCallbackData("⬅️ Назад", callbackData)); + } + + /// + /// Creates a confirmation inline keyboard for important actions. + /// + /// Callback data for confirm action. + /// Callback data for cancel action. + /// Configured inline keyboard markup for confirmation. + public InlineKeyboardMarkup GetConfirmationKeyboard(string confirmCallbackData = "confirm", string cancelCallbackData = "cancel") + { + logger.LogDebug("Creating confirmation keyboard with confirm: {Confirm}, cancel: {Cancel}", confirmCallbackData, cancelCallbackData); + + return new InlineKeyboardMarkup(new[] + { + new[] + { + InlineKeyboardButton.WithCallbackData("✅ Подтвердить", confirmCallbackData), + InlineKeyboardButton.WithCallbackData("❌ Отмена", cancelCallbackData), + }, + }); + } +} \ No newline at end of file diff --git a/Bot/Services/UserStateService.cs b/Bot/Services/UserStateService.cs new file mode 100755 index 0000000..d69abf0 --- /dev/null +++ b/Bot/Services/UserStateService.cs @@ -0,0 +1,114 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Microsoft.Extensions.Logging; + +namespace Bot.Services; + +/// +/// Manages user state for tracking user interactions and input expectations. +/// +public class UserStateService +{ + private readonly ILogger logger; + private readonly Dictionary waitingForAmount = []; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + public UserStateService(ILogger logger) + { + this.logger = logger; + this.logger.LogDebug("UserStateService initialized"); + } + + /// + /// Sets a user to waiting for amount input state. + /// + /// The user identifier. + /// The chat identifier. + public virtual void SetWaitingForAmount(long userId, long chatId) + { + waitingForAmount[userId] = chatId; + logger.LogDebug("User {UserId} set to waiting for amount input in chat {ChatId}", userId, chatId); + } + + /// + /// Checks if a user is waiting for amount input in the specified chat. + /// + /// The user identifier. + /// The chat identifier. + /// True if the user is waiting for amount input in the specified chat; otherwise, false. + public virtual bool IsWaitingForAmount(long userId, long chatId) + { + var isWaiting = waitingForAmount.TryGetValue(userId, out var waitingChatId) && waitingChatId == chatId; + + logger.LogDebug( + "Checked waiting for amount status for user {UserId} in chat {ChatId}: {IsWaiting}", userId, chatId, isWaiting); + + return isWaiting; + } + + /// + /// Removes a user from waiting for amount input state. + /// + /// The user identifier. + public virtual void RemoveWaitingForAmount(long userId) + { + var removed = waitingForAmount.Remove(userId); + + if (removed) + { + logger.LogDebug("Removed user {UserId} from waiting for amount state", userId); + } + else + { + logger.LogDebug("Attempted to remove non-existent waiting state for user {UserId}", userId); + } + } + + /// + /// Gets the number of users currently waiting for amount input. + /// + /// The count of users in waiting for amount state. + public int GetWaitingUsersCount() + { + var count = waitingForAmount.Count; + logger.LogTrace("Current users waiting for amount input: {Count}", count); + return count; + } + + /// + /// Clears all waiting states (for maintenance or reset scenarios). + /// + public void ClearAllWaitingStates() + { + var count = waitingForAmount.Count; + waitingForAmount.Clear(); + logger.LogInformation("Cleared all waiting states, affected {Count} users", count); + } + + /// + /// Removes waiting state for multiple users at once. + /// + /// The collection of user identifiers to remove. + /// The number of users successfully removed. + public int RemoveMultipleWaitingStates(IEnumerable userIds) + { + var removedCount = 0; + + foreach (var userId in userIds) + { + if (waitingForAmount.Remove(userId)) + { + removedCount++; + } + } + + logger.LogDebug("Removed waiting states for {RemovedCount} out of {TotalCount} users", removedCount, userIds.Count()); + + return removedCount; + } +} \ No newline at end of file diff --git a/Bot/UpdateHandler.cs b/Bot/UpdateHandler.cs new file mode 100755 index 0000000..22acd4f --- /dev/null +++ b/Bot/UpdateHandler.cs @@ -0,0 +1,131 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Handlers; +using Microsoft.Extensions.Logging; +using Telegram.Bot; +using Telegram.Bot.Exceptions; +using Telegram.Bot.Polling; +using Telegram.Bot.Types; + +namespace Bot; + +/// +/// Handles incoming updates from Telegram Bot API and routes them to appropriate handlers. +/// +public class UpdateHandler : IUpdateHandler +{ + private readonly ILogger logger; + private readonly IEnumerable handlers; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance for tracking operations. + /// Collection of update handler commands. + public UpdateHandler( + ILogger logger, + IEnumerable handlers) + { + this.logger = logger; + this.handlers = handlers; + + this.logger.LogDebug("UpdateHandler initialized with {HandlerCount} handlers", handlers.Count()); + } + + /// + /// Processes incoming updates by routing them to appropriate handlers. + /// + /// Telegram bot client instance. + /// The update to process. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public async Task HandleUpdateAsync(ITelegramBotClient botClient, Update update, CancellationToken cancellationToken) + { + logger.LogDebug("Received update {UpdateId} of type {UpdateType}", update.Id, update.Type); + + try + { + var handler = handlers.FirstOrDefault(h => h.CanHandle(update)); + + if (handler != null) + { + logger.LogDebug("Routing update {UpdateId} to handler {HandlerType}", update.Id, handler.GetType().Name); + + await handler.HandleAsync(botClient, update, cancellationToken); + } + else + { + logger.LogWarning("No handler found for update {UpdateId} of type {UpdateType}", update.Id, update.Type); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing update {UpdateId} of type {UpdateType}", update.Id, update.Type); + } + } + + /// + /// Handles errors that occur during update processing. + /// + /// Telegram bot client instance. + /// The exception that occurred. + /// The source of the error. + /// Cancellation token for async operations. + /// A representing the asynchronous operation. + public async Task HandleErrorAsync(ITelegramBotClient botClient, Exception exception, HandleErrorSource errorSource, CancellationToken cancellationToken) + { + var errorMessage = exception switch + { + ApiRequestException apiRequestException + => $"Telegram API Error: [{apiRequestException.ErrorCode}] {apiRequestException.Message}", + RequestException requestException + => $"Request Error: {requestException.Message}", + _ => exception.Message + }; + + logger.LogError(exception, "Error occurred from source {ErrorSource}. Message: {ErrorMessage}", errorSource, errorMessage); + + // Implement retry delay for network-related errors + if (exception is HttpRequestException or RequestException) + { + logger.LogInformation("Network error detected, waiting 1 second before retry"); + + try + { + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + catch (TaskCanceledException) + { + logger.LogDebug("Retry delay was cancelled"); + } + } + + // Log specific API error codes for monitoring + if (exception is ApiRequestException apiEx) + { + LogApiErrorDetails(apiEx); + } + } + + /// + /// Logs detailed information about Telegram API errors for monitoring and debugging. + /// + /// The API request exception. + private void LogApiErrorDetails(ApiRequestException apiException) + { + var logLevel = apiException.ErrorCode switch + { + 400 => LogLevel.Warning, // Bad Request + 401 => LogLevel.Critical, // Unauthorized + 403 => LogLevel.Warning, // Forbidden + 404 => LogLevel.Information, // Not Found + 429 => LogLevel.Warning, // Too Many Requests + 500 => LogLevel.Error, // Internal Server Error + _ => LogLevel.Error + }; + + logger.Log(logLevel, apiException, "Telegram API Error {ErrorCode}: {ErrorMessage}", apiException.ErrorCode, apiException.Message); + } +} \ No newline at end of file diff --git a/Bot/appsettings.json b/Bot/appsettings.json new file mode 100755 index 0000000..987fd3a --- /dev/null +++ b/Bot/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "BotConfig": { + "BotToken": "", + "PaymentProviderToken": "" + }, + "DatabaseConfig": { + "ConnectionString": "" + } +} \ No newline at end of file diff --git a/Configurations/BotConfig.cs b/Configurations/BotConfig.cs new file mode 100755 index 0000000..f13e1e4 --- /dev/null +++ b/Configurations/BotConfig.cs @@ -0,0 +1,27 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Configurations; + +/// +/// Represents the configuration settings for the bot application. +/// +public class BotConfig +{ + /// + /// Gets or sets the bot token used for authenticating with the Telegram Bot API. + /// + /// + /// The bot token string. This should be kept secure and not exposed publicly. + /// + public string BotToken { get; set; } = string.Empty; + + /// + /// Gets or sets the payment provider token for processing donations. + /// + /// + /// The payment provider token string. Used for integrating with payment systems. + /// + public string PaymentProviderToken { get; set; } = string.Empty; +} diff --git a/Configurations/Configurations.csproj b/Configurations/Configurations.csproj new file mode 100755 index 0000000..21bcc02 --- /dev/null +++ b/Configurations/Configurations.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/Configurations/DatabaseConfig.cs b/Configurations/DatabaseConfig.cs new file mode 100755 index 0000000..e8ab896 --- /dev/null +++ b/Configurations/DatabaseConfig.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Configurations; + +/// +/// Represents database configuration settings for the application. +/// +public class DatabaseConfig +{ + /// + /// Gets or sets the connection string used to connect to the database. + /// + /// + /// The connection string containing server, database, and authentication information. + /// + /// + /// Server=localhost;Database=MyDb;UserId=myuser;Password=mypassword;. + /// + public string ConnectionString { get; set; } = string.Empty; +} diff --git a/Data/DapperRepository.cs b/Data/DapperRepository.cs new file mode 100755 index 0000000..efcf7d8 --- /dev/null +++ b/Data/DapperRepository.cs @@ -0,0 +1,347 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Configurations; +using Dapper; +using Data.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace Data; + +/// +/// Provides data access operations for the application using Dapper micro-ORM. +/// Implements the interface. +/// +public class DapperRepository : IDapperRepository +{ + private readonly string connectionString; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// Database configuration containing the connection string. + /// Logger instance for tracking operations. + public DapperRepository(IOptions config, ILogger logger) + { + connectionString = config.Value.ConnectionString; + this.logger = logger; + this.logger.LogDebug("DapperRepository initialized"); + } + + /// + /// Retrieves a user by their Telegram identifier. + /// + /// The Telegram user identifier to search for. + /// + /// A object if found; otherwise, null. + /// + public async Task GetUserByTelegramIdAsync(long telegramId) + { + logger.LogDebug("Retrieving user by Telegram ID: {TelegramId}", telegramId); + + try + { + using var connection = new NpgsqlConnection(connectionString); + var user = await connection.QueryFirstOrDefaultAsync( + "SELECT * FROM users WHERE telegram_id = @TelegramId", + new { TelegramId = telegramId }); + + logger.LogDebug("User retrieval {(Result)} for Telegram ID: {TelegramId}", user != null ? "succeeded" : "failed - not found", telegramId); + + return user; + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving user by Telegram ID: {TelegramId}", telegramId); + throw; + } + } + + /// + /// Creates a new user in the database. + /// + /// The user object containing the data to insert. + /// + /// The created object with the generated identifier. + /// + public async Task CreateUserAsync(Users user) + { + logger.LogDebug("Creating user with Telegram ID: {TelegramId}", user.TelegramId); + + try + { + using var connection = new NpgsqlConnection(connectionString); + var sql = @" + INSERT INTO users (telegram_id, username, first_name, last_name) + VALUES (@TelegramId, @Username, @FirstName, @LastName) + RETURNING *"; + + var createdUser = await connection.QueryFirstAsync(sql, user); + logger.LogInformation( + "User created successfully with ID: {UserId} for Telegram ID: {TelegramId}", createdUser.Id, user.TelegramId); + + return createdUser; + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating user with Telegram ID: {TelegramId}", user.TelegramId); + throw; + } + } + + /// + /// Retrieves the currently active donation goal. + /// + /// + /// An active object if found; otherwise, null. + /// + public async Task GetActiveGoalAsync() + { + logger.LogDebug("Retrieving active donation goal"); + + try + { + using var connection = new NpgsqlConnection(connectionString); + var sql = @" + SELECT + id AS Id, + title AS Title, + description AS Description, + target_amount AS TargetAmount, + current_amount AS CurrentAmount, + is_active AS IsActive, + created_at AS CreatedAt + FROM donation_goals + WHERE is_active = true + ORDER BY created_at DESC + LIMIT 1"; + + var goal = await connection.QueryFirstOrDefaultAsync(sql); + logger.LogDebug( + "Active goal retrieval {(Result)}", goal != null ? $"succeeded - Goal ID: {goal.Id}" : "failed - no active goal"); + + return goal; + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving active donation goal"); + throw; + } + } + + /// + /// Gets the total number of donations made to active goals. + /// + /// + /// The count of donations for active goals. + /// + public async Task GetCountDonationsForActiveGoals() + { + logger.LogDebug("Retrieving count of donations for active goals"); + + try + { + using var connection = new NpgsqlConnection(connectionString); + var sql = @" + SELECT COUNT(*) + FROM donations d + INNER JOIN donation_goals g ON d.goal_id = g.id + WHERE g.is_active = true"; + + var count = await connection.QueryFirstOrDefaultAsync(sql); + logger.LogDebug("Retrieved {DonationCount} donations for active goals", count); + + return count; + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving count of donations for active goals"); + throw; + } + } + + /// + /// Gets the number of unique users who have donated to active goals. + /// + /// + /// The count of unique users for active goals. + /// + public async Task GetCountUsersForActiveGoals() + { + logger.LogDebug("Retrieving count of unique users for active goals"); + + try + { + using var connection = new NpgsqlConnection(connectionString); + var sql = @" + SELECT COUNT(DISTINCT d.user_telegram_id) + FROM donations d + INNER JOIN donation_goals g ON d.goal_id = g.id + WHERE g.is_active = true"; + + var count = await connection.QueryFirstOrDefaultAsync(sql); + logger.LogDebug("Retrieved {UserCount} unique users for active goals", count); + + return count; + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving count of unique users for active goals"); + throw; + } + } + + /// + /// Creates a new donation goal and deactivates any previously active goal. + /// + /// The donation goal object containing the data to insert. + /// + /// The created object with the generated identifier. + /// + public async Task CreateGoalAsync(DonationGoal goal) + { + logger.LogInformation("Creating new donation goal: {GoalTitle}", goal.Title); + + try + { + using var connection = new NpgsqlConnection(connectionString); + var sql = @" + UPDATE donation_goals SET is_active = false WHERE is_active = true; + + INSERT INTO donation_goals (title, description, target_amount) + VALUES (@Title, @Description, @TargetAmount) + RETURNING *"; + + var createdGoal = await connection.QueryFirstAsync(sql, goal); + logger.LogInformation( + "Goal created successfully with ID: {GoalId}, Target: {TargetAmount}", createdGoal.Id, createdGoal.TargetAmount); + + return createdGoal; + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating donation goal: {GoalTitle}", goal.Title); + throw; + } + } + + /// + /// Updates the current amount of a donation goal by adding the specified amount. + /// + /// The identifier of the goal to update. + /// The amount to add to the goal's current amount. + public async Task UpdateGoalCurrentAmountAsync(int goalId, decimal amountToAdd) + { + logger.LogDebug("Updating goal {GoalId} current amount by {AmountToAdd}", goalId, amountToAdd); + + try + { + using var connection = new NpgsqlConnection(connectionString); + var sql = @" + UPDATE donation_goals + SET current_amount = current_amount + @AmountToAdd + WHERE id = @GoalId"; + + var affectedRows = await connection.ExecuteAsync(sql, new { GoalId = goalId, AmountToAdd = amountToAdd }); + + if (affectedRows == 0) + { + logger.LogWarning("No rows affected when updating goal {GoalId} amount", goalId); + } + else + { + logger.LogDebug("Successfully updated goal {GoalId} amount by {AmountToAdd}", goalId, amountToAdd); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error updating goal {GoalId} current amount by {AmountToAdd}", goalId, amountToAdd); + throw; + } + } + + /// + /// Creates a new donation record in the database. + /// + /// The donation object containing the data to insert. + /// + /// The created object with the generated identifier. + /// + public async Task CreateDonationAsync(Donation donation) + { + logger.LogDebug( + "Creating donation for user {UserTelegramId} with amount {Amount} {Currency}", donation.UserTelegramId, donation.Amount, donation.Currency); + + try + { + using var connection = new NpgsqlConnection(connectionString); + var sql = @" + INSERT INTO donations (user_telegram_id, goal_id, amount, currency, provider_payment_id, status) + VALUES (@UserTelegramId, @GoalId, @Amount, @Currency, @ProviderPaymentId, @Status) + RETURNING + id AS Id, + user_telegram_id AS UserTelegramId, + goal_id AS GoalId, + amount AS Amount, + currency AS Currency, + status AS Status, + provider_payment_id AS ProviderPaymentId, + created_at AS CreatedAt"; + + var createdDonation = await connection.QueryFirstAsync(sql, donation); + logger.LogInformation( + "Donation created successfully with ID: {DonationId} for user {UserTelegramId}", createdDonation.Id, donation.UserTelegramId); + + return createdDonation; + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating donation for user {UserTelegramId}", donation.UserTelegramId); + throw; + } + } + + /// + /// Retrieves a donation by its payment provider identifier. + /// + /// The payment provider's donation identifier. + /// + /// A object if found; otherwise, null. + /// + public async Task GetDonationAsync(string donationId) + { + logger.LogDebug("Retrieving donation by ID: {DonationId}", donationId); + + try + { + using var connection = new NpgsqlConnection(connectionString); + var sql = @" + SELECT + id AS Id, + user_telegram_id AS UserTelegramId, + goal_id AS GoalId, + amount AS Amount, + currency AS Currency, + provider_payment_id AS ProviderPaymentId, + status AS Status, + created_at AS CreatedAt + FROM donations + WHERE provider_payment_id = @DonationId"; + + var donation = await connection.QueryFirstOrDefaultAsync(sql, new { DonationId = donationId }); + logger.LogDebug( + "Donation retrieval {(Result)} for ID: {DonationId}", donation != null ? "succeeded" : "failed - not found", donationId); + + return donation; + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving donation by ID: {DonationId}", donationId); + throw; + } + } +} \ No newline at end of file diff --git a/Data/Data.csproj b/Data/Data.csproj new file mode 100755 index 0000000..a7b4289 --- /dev/null +++ b/Data/Data.csproj @@ -0,0 +1,23 @@ + + + 10.0 + + + net9.0 + enable + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + \ No newline at end of file diff --git a/Data/IDapperRepository.cs b/Data/IDapperRepository.cs new file mode 100755 index 0000000..a68732f --- /dev/null +++ b/Data/IDapperRepository.cs @@ -0,0 +1,74 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Data.Models; + +namespace Data; + +/// +/// Repository interface for data access operations using Dapper ORM. +/// +public interface IDapperRepository +{ + /// + /// Retrieves a user by their Telegram identifier. + /// + /// The Telegram user identifier. + /// The user if found; otherwise, null. + Task GetUserByTelegramIdAsync(long telegramId); + + /// + /// Creates a new user in the database. + /// + /// The user entity to create. + /// The created user with generated identifier. + Task CreateUserAsync(Users user); + + /// + /// Retrieves the currently active donation goal. + /// + /// The active donation goal if found; otherwise, null. + Task GetActiveGoalAsync(); + + /// + /// Gets the count of unique users who donated to active goals. + /// + /// The number of unique donors for active goals. + Task GetCountUsersForActiveGoals(); + + /// + /// Gets the total count of donations made to active goals. + /// + /// The total number of donations for active goals. + Task GetCountDonationsForActiveGoals(); + + /// + /// Creates a new donation goal in the database. + /// + /// The donation goal entity to create. + /// The created donation goal with generated identifier. + Task CreateGoalAsync(DonationGoal goal); + + /// + /// Updates the current collected amount for a specific goal. + /// + /// The identifier of the goal to update. + /// The new current amount value. + /// A task representing the asynchronous operation. + Task UpdateGoalCurrentAmountAsync(int goalId, decimal newAmount); + + /// + /// Creates a new donation record in the database. + /// + /// The donation entity to create. + /// The created donation with generated identifier. + Task CreateDonationAsync(Donation donation); + + /// + /// Retrieves a donation by its unique identifier. + /// + /// The donation identifier. + /// The donation if found; otherwise, null. + Task GetDonationAsync(string donationId); +} \ No newline at end of file diff --git a/Data/Models/Donation.cs b/Data/Models/Donation.cs new file mode 100755 index 0000000..0a40b84 --- /dev/null +++ b/Data/Models/Donation.cs @@ -0,0 +1,75 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Data.Models; + +/// +/// Represents a donation made by a user towards a specific goal. +/// +public class Donation +{ + /// + /// Gets or sets the unique identifier for the donation. + /// + /// + /// The donation identifier. + /// + public int Id { get; set; } + + /// + /// Gets or sets the Telegram user identifier of the donor. + /// + /// + /// The Telegram user identifier. + /// + public long UserTelegramId { get; set; } + + /// + /// Gets or sets the identifier of the goal this donation is for. + /// + /// + /// The goal identifier. + /// + public int GoalId { get; set; } + + /// + /// Gets or sets the amount of the donation. + /// + /// + /// The donation amount in the specified currency. + /// + public decimal Amount { get; set; } + + /// + /// Gets or sets the currency of the donation. Default is "RUB" (Russian Ruble). + /// + /// + /// The 3-letter currency code (ISO 4217). + /// + public string Currency { get; set; } = "RUB"; + + /// + /// Gets or sets the payment provider's transaction identifier. + /// + /// + /// The payment provider's unique transaction identifier, or null if not available. + /// + public string? ProviderPaymentId { get; set; } + + /// + /// Gets or sets the current status of the donation. Default is "pending". + /// + /// + /// The donation status (e.g., "pending", "completed", "failed", "refunded"). + /// + public string Status { get; set; } = "pending"; + + /// + /// Gets or sets the date and time when the donation was created. + /// + /// + /// The creation timestamp in UTC. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Data/Models/DonationGoal.cs b/Data/Models/DonationGoal.cs new file mode 100755 index 0000000..e54efc5 --- /dev/null +++ b/Data/Models/DonationGoal.cs @@ -0,0 +1,67 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Data.Models; + +/// +/// Represents a fundraising goal with a target amount and current progress. +/// +public class DonationGoal +{ + /// + /// Gets or sets the unique identifier for the donation goal. + /// + /// + /// The goal identifier. + /// + public int Id { get; set; } + + /// + /// Gets or sets the title of the donation goal. + /// + /// + /// The goal title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the optional description providing more details about the goal. + /// + /// + /// The goal description, or null if not provided. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the target amount to be raised for this goal. + /// + /// + /// The target amount in the base currency. + /// + public decimal TargetAmount { get; set; } + + /// + /// Gets or sets the current amount raised towards the target. + /// + /// + /// The current amount raised. + /// + public decimal CurrentAmount { get; set; } + + /// + /// Gets or sets a value indicating whether the goal is currently active and accepting donations. + /// + /// + /// true if the goal is active; otherwise, false. + /// + public bool IsActive { get; set; } = true; + + /// + /// Gets or sets the date and time when the goal was created. + /// + /// + /// The creation timestamp in UTC. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/Data/Models/Users.cs b/Data/Models/Users.cs new file mode 100755 index 0000000..abf0b9f --- /dev/null +++ b/Data/Models/Users.cs @@ -0,0 +1,46 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +namespace Data.Models; + +/// +/// Represents a user in the system. +/// +public class Users +{ + /// + /// Gets or sets the unique identifier for the user. + /// + public int Id { get; set; } + + /// + /// Gets or sets a value indicating whether the user has administrative privileges. + /// + public bool Admin { get; set; } + + /// + /// Gets or sets the user's Telegram identifier. + /// + public long TelegramId { get; set; } + + /// + /// Gets or sets the user's Telegram username (optional). + /// + public string? Username { get; set; } + + /// + /// Gets or sets the user's first name (optional). + /// + public string? FirstName { get; set; } + + /// + /// Gets or sets the user's last name (optional). + /// + public string? LastName { get; set; } + + /// + /// Gets or sets the date and time when the user record was created. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/Data/Scripts/create_tables.sql b/Data/Scripts/create_tables.sql new file mode 100755 index 0000000..1816359 --- /dev/null +++ b/Data/Scripts/create_tables.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + telegram_id BIGINT NOT NULL UNIQUE, + username VARCHAR(100), + first_name VARCHAR(100), + last_name VARCHAR(100), + admin BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS donation_goals ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + target_amount DECIMAL(10, 2) NOT NULL, + current_amount DECIMAL(10, 2) DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS donations ( + id SERIAL PRIMARY KEY, + user_telegram_id BIGINT NOT NULL, + goal_id INTEGER NOT NULL, + amount DECIMAL(10, 2) NOT NULL, + currency VARCHAR(3) DEFAULT 'RUB', + provider_payment_id VARCHAR(255) NOT NULL, + status VARCHAR(50) DEFAULT 'pending', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- Ограничения + CONSTRAINT chk_donation_amount_positive CHECK (amount > 0), + CONSTRAINT uq_provider_payment_id UNIQUE (provider_payment_id), + + -- Внешние ключи + CONSTRAINT fk_donations_user_id + FOREIGN KEY (user_telegram_id) + REFERENCES users(telegram_id) ON DELETE RESTRICT, + + CONSTRAINT fk_donations_goal_id + FOREIGN KEY (goal_id) + REFERENCES donation_goals(id) ON DELETE RESTRICT +); + +-- Индексы +CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id); +CREATE INDEX IF NOT EXISTS idx_donation_goals_active ON donation_goals(is_active) WHERE is_active = true; +CREATE INDEX IF NOT EXISTS idx_donations_user_telegram_id ON donations(user_telegram_id); +CREATE INDEX IF NOT EXISTS idx_donations_goal_id ON donations(goal_id); \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..23e7710 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Копируем файлы проектов +COPY ["Bot/Bot.csproj", "Bot/"] +COPY ["Data/Data.csproj", "Data/"] +COPY ["Services/Services.csproj", "Services/"] +COPY ["Configurations/Configurations.csproj", "Configurations/"] +RUN dotnet restore "Bot/Bot.csproj" + +# Копируем весь код и публикуем +COPY . . +WORKDIR "/src/Bot" +RUN dotnet publish "Bot.csproj" -c Release -o /app/publish + +# Финальный образ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "Bot.dll"] \ No newline at end of file diff --git a/DonationBot.sln b/DonationBot.sln new file mode 100755 index 0000000..b8f5dad --- /dev/null +++ b/DonationBot.sln @@ -0,0 +1,103 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{5562041A-E13F-4D1F-8D4A-F275232C57CB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Services", "Services\Services.csproj", "{62104FDE-4949-4FB5-B509-541CF8078D02}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Configurations", "Configurations\Configurations.csproj", "{499B7406-86EF-4C81-995B-1ABAD3CD7BA1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bot", "Bot\Bot.csproj", "{BCABA8EA-B3EF-4B25-9481-9CAA38D95379}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Элементы решения", "Элементы решения", "{754FC069-D67B-A9D7-50A1-8D1CA196D8F1}" + ProjectSection(SolutionItems) = preProject + docker-compose.yml = docker-compose.yml + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestBot", "TestBot\TestBot.csproj", "{164F484F-C030-47B5-A8FC-E3FEA0E20318}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{36D591C7-65C7-A0D1-1CBC-10CDE441BDC8}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|x64.ActiveCfg = Debug|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|x64.Build.0 = Debug|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|x86.ActiveCfg = Debug|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Debug|x86.Build.0 = Debug|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|Any CPU.Build.0 = Release|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|x64.ActiveCfg = Release|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|x64.Build.0 = Release|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|x86.ActiveCfg = Release|Any CPU + {5562041A-E13F-4D1F-8D4A-F275232C57CB}.Release|x86.Build.0 = Release|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|x64.ActiveCfg = Debug|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|x64.Build.0 = Debug|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|x86.ActiveCfg = Debug|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Debug|x86.Build.0 = Debug|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|Any CPU.Build.0 = Release|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|x64.ActiveCfg = Release|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|x64.Build.0 = Release|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|x86.ActiveCfg = Release|Any CPU + {62104FDE-4949-4FB5-B509-541CF8078D02}.Release|x86.Build.0 = Release|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|x64.ActiveCfg = Debug|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|x64.Build.0 = Debug|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|x86.ActiveCfg = Debug|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Debug|x86.Build.0 = Debug|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|Any CPU.Build.0 = Release|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|x64.ActiveCfg = Release|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|x64.Build.0 = Release|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|x86.ActiveCfg = Release|Any CPU + {499B7406-86EF-4C81-995B-1ABAD3CD7BA1}.Release|x86.Build.0 = Release|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|x64.ActiveCfg = Debug|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|x64.Build.0 = Debug|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|x86.ActiveCfg = Debug|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Debug|x86.Build.0 = Debug|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|Any CPU.Build.0 = Release|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|x64.ActiveCfg = Release|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|x64.Build.0 = Release|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|x86.ActiveCfg = Release|Any CPU + {BCABA8EA-B3EF-4B25-9481-9CAA38D95379}.Release|x86.Build.0 = Release|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|Any CPU.Build.0 = Debug|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|x64.ActiveCfg = Debug|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|x64.Build.0 = Debug|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|x86.ActiveCfg = Debug|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Debug|x86.Build.0 = Debug|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|Any CPU.ActiveCfg = Release|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|Any CPU.Build.0 = Release|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|x64.ActiveCfg = Release|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|x64.Build.0 = Release|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|x86.ActiveCfg = Release|Any CPU + {164F484F-C030-47B5-A8FC-E3FEA0E20318}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {021B4F17-39EA-4135-981B-3A3D4D1EA000} + EndGlobalSection +EndGlobal diff --git a/Services/DonationService.cs b/Services/DonationService.cs new file mode 100755 index 0000000..17fbb0d --- /dev/null +++ b/Services/DonationService.cs @@ -0,0 +1,157 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Data; +using Data.Models; +using Microsoft.Extensions.Logging; + +namespace Services; + +/// +/// Provides services for managing user donations and user data operations. +/// Implements the interface. +/// +public class DonationService : IDonationService +{ + private readonly IDapperRepository repository; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// Data repository for database operations. + /// Logger instance for tracking operations. + public DonationService(IDapperRepository repository, ILogger logger) + { + this.repository = repository; + this.logger = logger; + this.logger.LogDebug("DonationService initialized"); + } + + /// + /// Retrieves an existing user by Telegram ID or creates a new user if not found. + /// + /// The Telegram user identifier. + /// The Telegram username (optional). + /// The user's first name (optional). + /// The user's last name (optional). + /// + /// The existing or newly created object. + /// + /// + /// This method implements a "get or create" pattern for user management. + /// + public async Task GetOrCreateUserAsync(long telegramId, string? username, string? firstName, string? lastName) + { + logger.LogDebug("Getting or creating user with Telegram ID: {TelegramId}", telegramId); + + try + { + var user = await repository.GetUserByTelegramIdAsync(telegramId); + + if (user != null) + { + logger.LogDebug("Found existing user {UserId} for Telegram ID: {TelegramId}", user.Id, telegramId); + return user; + } + + logger.LogInformation( + "Creating new user for Telegram ID: {TelegramId}, username: {Username}", telegramId, username); + + var newUser = new Users + { + TelegramId = telegramId, + Username = username, + FirstName = firstName, + LastName = lastName, + Admin = false, + }; + + var createdUser = await repository.CreateUserAsync(newUser); + logger.LogInformation( + "Created new user {UserId} for Telegram ID: {TelegramId}", createdUser.Id, telegramId); + + return createdUser; + } + catch (Exception ex) + { + logger.LogError(ex, "Error getting or creating user with Telegram ID: {TelegramId}", telegramId); + throw; + } + } + + /// + /// Processes a donation transaction and updates the relevant goal's progress. + /// + /// The Telegram identifier of the user making the donation. + /// The donation amount. + /// The currency code of the donation. + /// The unique identifier provided by the payment provider. + /// + /// true if the donation was processed successfully; otherwise, false. + /// + public async Task ProcessDonationAsync(long userTelegramId, decimal amount, string currency, string donationId) + { + logger.LogInformation( + "Processing donation {DonationId} for user {UserTelegramId}, amount: {Amount} {Currency}", donationId, userTelegramId, amount, currency); + + try + { + var existingDonation = await repository.GetDonationAsync(donationId); + if (existingDonation != null) + { + logger.LogWarning( + "Donation {DonationId} has already been processed on {ProcessedDate}", donationId, existingDonation.CreatedAt); + return true; + } + + var goal = await repository.GetActiveGoalAsync(); + if (goal == null) + { + logger.LogError("No active goal found for donation {DonationId}", donationId); + return false; + } + + logger.LogDebug("Found active goal {GoalId} for donation {DonationId}", goal.Id, donationId); + + var donation = new Donation + { + UserTelegramId = userTelegramId, + GoalId = goal.Id, + Amount = amount, + Currency = currency, + ProviderPaymentId = donationId, + Status = "completed", + }; + + var createdDonation = await repository.CreateDonationAsync(donation); + logger.LogDebug( + "Created donation record {DonationRecordId} for payment {DonationId}", createdDonation.Id, donationId); + + await repository.UpdateGoalCurrentAmountAsync(goal.Id, amount); + + logger.LogInformation( + "Successfully processed donation {DonationId} for user {UserTelegramId}. " + + "Amount: {Amount} {Currency} added to goal {GoalId}", + donationId, + userTelegramId, + amount, + currency, + goal.Id); + + return true; + } + catch (Exception ex) + { + logger.LogError( + ex, + "Error processing donation {DonationId} for user {UserTelegramId}, amount: {Amount} {Currency}", + donationId, + userTelegramId, + amount, + currency); + return false; + } + } +} \ No newline at end of file diff --git a/Services/GoalService.cs b/Services/GoalService.cs new file mode 100755 index 0000000..9e233f6 --- /dev/null +++ b/Services/GoalService.cs @@ -0,0 +1,228 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Data; +using Data.Models; +using Microsoft.Extensions.Logging; + +namespace Services; + +/// +/// Service implementation for handling goal-related operations including statistics and administrative functions. +/// +public class GoalService : IGoalService +{ + private readonly IDapperRepository repository; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The repository for data access operations. + /// Logger instance for tracking operations. + public GoalService(IDapperRepository repository, ILogger logger) + { + this.repository = repository; + this.logger = logger; + + this.logger.LogDebug("GoalService initialized"); + } + + /// + public async Task IsUserAdminAsync(long telegramId) + { + logger.LogDebug("Checking admin status for user {TelegramId}", telegramId); + + try + { + var user = await repository.GetUserByTelegramIdAsync(telegramId); + var isAdmin = user?.Admin ?? false; + + logger.LogDebug("User {TelegramId} admin status: {IsAdmin}", telegramId, isAdmin); + + return isAdmin; + } + catch (Exception ex) + { + logger.LogError(ex, "Error checking admin status for user {TelegramId}", telegramId); + return false; + } + } + + /// + public async Task GetActiveGoalAsync() + { + logger.LogDebug("Retrieving active goal"); + + try + { + var goal = await repository.GetActiveGoalAsync(); + + logger.LogDebug("Active goal retrieval {(Result)}", goal != null ? $"succeeded - Goal ID: {goal.Id}" : "failed - no active goal"); + + return goal; + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving active goal"); + return null; + } + } + + /// + public async Task GetGoalStatsAsync() + { + logger.LogDebug("Generating goal statistics"); + + try + { + var goal = await GetActiveGoalAsync(); + var countUsers = await GetActiveUsersGoalAsync(); + var countDonations = await GetActiveDonationsGoalAsync(); + + if (goal == null) + { + logger.LogInformation("No active goal found for statistics"); + return "🎯 На данный момент нет активных целей для сбора."; + } + + var percent = goal.TargetAmount > 0 + ? ((double)goal.CurrentAmount / (double)goal.TargetAmount) * 100 + : 0; + + var progressBar = CreateProgressBar(percent); + + var stats = $"🎯 **{goal.Title}** — {goal.TargetAmount:N0}₽ \n📝 Описание: {goal.Description} \n\n📈 Количество пожертвований на текущую цель: {countDonations}" + + $"\n🧮 Количество пожертвовавших: {countUsers} \n⏳ Дата открытия сбора: {goal.CreatedAt:dd.MM.yyyy}\n\nСобрано: {goal.CurrentAmount:N0}₽ ({percent:F1}%) \n{progressBar}"; + + logger.LogDebug("Goal statistics generated successfully for goal {GoalId}", goal.Id); + + return stats; + } + catch (Exception ex) + { + logger.LogError(ex, "Error generating goal statistics"); + return "❌ Произошла ошибка при получении статистики."; + } + } + + /// + public async Task GetStartStats() + { + logger.LogDebug("Generating start statistics"); + + try + { + var goal = await GetActiveGoalAsync(); + + if (goal == null) + { + logger.LogInformation("No active goal found for start statistics"); + return "🎯 На данный момент нет активных целей для сбора."; + } + + var percent = goal.TargetAmount > 0 + ? ((double)goal.CurrentAmount / (double)goal.TargetAmount) * 100 + : 0; + + var progressBar = CreateProgressBar(percent); + + var stats = $"🎯 **{goal.Title}** — {goal.TargetAmount:N0}₽ \n📝 Описание: {goal.Description} \n\nСобрано: {goal.CurrentAmount:N0}₽ ({percent:F1}%) \n{progressBar}"; + + logger.LogDebug("Start statistics generated successfully for goal {GoalId}", goal.Id); + + return stats; + } + catch (Exception ex) + { + logger.LogError(ex, "Error generating start statistics"); + return "❌ Произошла ошибка при получении статистики."; + } + } + + /// + public async Task CreateGoalAsync(string title, string description, decimal targetAmount) + { + logger.LogInformation("Creating new goal: {Title}, Target: {TargetAmount}", title, targetAmount); + + try + { + var newGoal = new DonationGoal + { + Title = title, + Description = description, + TargetAmount = targetAmount, + IsActive = true, + }; + + var createdGoal = await repository.CreateGoalAsync(newGoal); + + logger.LogInformation("Goal created successfully: {GoalId}, {Title}, Target: {TargetAmount}", createdGoal.Id, title, targetAmount); + + return createdGoal; + } + catch (Exception ex) + { + logger.LogError(ex, "Error creating goal: {Title}", title); + throw; + } + } + + private async Task GetActiveUsersGoalAsync() + { + logger.LogDebug("Retrieving active users count for goal"); + + try + { + var count = await repository.GetCountUsersForActiveGoals(); + + logger.LogDebug("Retrieved {UserCount} active users for goal", count); + + return count; + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving active users count for goal"); + return 0; + } + } + + private async Task GetActiveDonationsGoalAsync() + { + logger.LogDebug("Retrieving active donations count for goal"); + + try + { + var count = await repository.GetCountDonationsForActiveGoals(); + + logger.LogDebug("Retrieved {DonationCount} active donations for goal", count); + + return count; + } + catch (Exception ex) + { + logger.LogError(ex, "Error retrieving active donations count for goal"); + return 0; + } + } + + /// + /// Creates a visual progress bar representation. + /// + /// The completion percentage. + /// A string representing the progress bar. + private string CreateProgressBar(double percent) + { + if (percent >= 100) + { + return $"[{new string('■', 10)}]"; + } + else + { + var filled = (int)Math.Round(percent / 10); + var empty = 10 - filled; + return $"[{new string('■', filled)}{new string('□', empty)}]"; + } + } +} \ No newline at end of file diff --git a/Services/IDonationService.cs b/Services/IDonationService.cs new file mode 100755 index 0000000..4a2ef3f --- /dev/null +++ b/Services/IDonationService.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Data.Models; + +namespace Services; + +/// +/// Service for handling donation-related operations and user management. +/// +public interface IDonationService +{ + /// + /// Retrieves an existing user or creates a new one if not found. + /// + /// The Telegram user identifier. + /// The Telegram username (optional). + /// The user's first name (optional). + /// The user's last name (optional). + /// The existing or newly created user. + Task GetOrCreateUserAsync(long telegramId, string? username, string? firstName, string? lastName); + + /// + /// Processes a donation transaction and updates relevant records. + /// + /// The identifier of the user making the donation. + /// The donation amount. + /// The currency of the donation. + /// The unique payment provider identifier for the donation. + /// True if the donation was processed successfully; otherwise, false. + Task ProcessDonationAsync(long userId, decimal amount, string currency, string donationId); +} \ No newline at end of file diff --git a/Services/IGoalService.cs b/Services/IGoalService.cs new file mode 100755 index 0000000..f820dc5 --- /dev/null +++ b/Services/IGoalService.cs @@ -0,0 +1,47 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Data.Models; + +namespace Services; + +/// +/// Service for handling goal-related operations including statistics and administrative functions. +/// +public interface IGoalService +{ + /// + /// Checks if a user has administrator privileges. + /// + /// The Telegram user identifier. + /// True if the user is an administrator; otherwise, false. + Task IsUserAdminAsync(long telegramId); + + /// + /// Retrieves the currently active donation goal. + /// + /// The active donation goal if found; otherwise, null. + Task GetActiveGoalAsync(); + + /// + /// Gets startup statistics for display in the welcome message. + /// + /// Formatted statistics string for the start command. + Task GetStartStats(); + + /// + /// Gets detailed statistics for the current goal. + /// + /// Formatted statistics string for the stats command. + Task GetGoalStatsAsync(); + + /// + /// Creates a new donation goal with the specified parameters. + /// + /// The goal title. + /// The goal description. + /// The target amount for the goal. + /// The created donation goal. + Task CreateGoalAsync(string title, string description, decimal targetAmount); +} \ No newline at end of file diff --git a/Services/Services.csproj b/Services/Services.csproj new file mode 100755 index 0000000..8f42233 --- /dev/null +++ b/Services/Services.csproj @@ -0,0 +1,21 @@ + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + net9.0 + enable + enable + + + diff --git a/TestBot/Bot/Bot.Hadler/AdminHandlerTests.cs b/TestBot/Bot/Bot.Hadler/AdminHandlerTests.cs new file mode 100755 index 0000000..aa3f1d6 --- /dev/null +++ b/TestBot/Bot/Bot.Hadler/AdminHandlerTests.cs @@ -0,0 +1,498 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Handlers; +using Bot.Services; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Services; +using Telegram.Bot; +using Telegram.Bot.Types; +using static Bot.Services.AdminStateService; + +namespace Bot.Tests.Handlers; + +/// +/// Unit tests for the class. +/// +[TestFixture] +public class AdminHandlerTests +{ + private Mock> loggerMock; + private Mock goalServiceMock; + private Mock adminStateServiceMock; + private Mock botClientMock; + private AdminHandler handler; + + /// + /// Sets up the test environment before each test execution. + /// + [SetUp] + public void Setup() + { + loggerMock = new Mock>(); + goalServiceMock = new Mock(); + + // Создаем мок для AdminStateService с правильным конструктором + var adminStateServiceLoggerMock = new Mock>(); + adminStateServiceMock = new Mock(adminStateServiceLoggerMock.Object); + + botClientMock = new Mock(); + + handler = new AdminHandler( + loggerMock.Object, + goalServiceMock.Object, + adminStateServiceMock.Object); + } + + /// + /// Tests that a message with null user triggers a warning log and returns without processing. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_NullUser_LogsWarningAndReturns() + { + // Arrange + var message = new Message { From = null, Chat = new Chat { Id = 123 }, Text = "Test" }; + var cancellationToken = CancellationToken.None; + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Received message with null user ID")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a message from a user without admin state triggers a warning log and returns. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_NullState_LogsWarningAndReturns() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "Test" }; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(null as AdminGoalCreationState); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("No admin state found for user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a message with empty text triggers a warning log and returns. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_EmptyMessageText_LogsWarningAndReturns() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = null }; + var state = new AdminGoalCreationState { CurrentStep = AdminGoalStep.WaitingForTitle }; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(state); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Received empty message text")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a valid title is processed correctly during the title step. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_WaitingForTitle_ValidTitle_ProcessesTitleStep() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "Новая цель" }; + var state = new AdminGoalCreationState { CurrentStep = AdminGoalStep.WaitingForTitle }; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(state); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + adminStateServiceMock.Verify( + x => x.SetTitle(123, "Новая цель"), + Times.Once); + } + + /// + /// Tests that a title exceeding the length limit triggers cancellation of goal creation. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_WaitingForTitle_TooLongTitle_CancelsCreation() + { + // Arrange + var user = new User { Id = 123 }; + var longTitle = new string('a', 256); + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = longTitle }; + var state = new AdminGoalCreationState { CurrentStep = AdminGoalStep.WaitingForTitle }; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(state); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + adminStateServiceMock.Verify( + x => x.CancelGoalCreation(123), + Times.Once); + + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("provided title that exceeds length limit")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a valid description is processed correctly during the description step. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_WaitingForDescription_ProcessesDescriptionStep() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "Описание цели" }; + var state = new AdminGoalCreationState { CurrentStep = AdminGoalStep.WaitingForDescription }; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(state); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + adminStateServiceMock.Verify( + x => x.SetDescription(123, "Описание цели"), + Times.Once); + } + + /// + /// Tests that an invalid amount triggers cancellation of goal creation. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_WaitingForAmount_InvalidAmount_CancelsCreation() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "invalid_amount" }; + var state = new AdminGoalCreationState + { + CurrentStep = AdminGoalStep.WaitingForAmount, + Title = "Test Goal", + Description = "Test Description", + }; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(state); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + adminStateServiceMock.Verify( + x => x.CancelGoalCreation(123), + Times.Once); + + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("provided invalid amount")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that an amount exceeding the maximum limit triggers cancellation of goal creation. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_WaitingForAmount_AmountTooLarge_CancelsCreation() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "100000000" }; + var state = new AdminGoalCreationState + { + CurrentStep = AdminGoalStep.WaitingForAmount, + Title = "Test Goal", + Description = "Test Description", + }; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(state); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + adminStateServiceMock.Verify( + x => x.CancelGoalCreation(123), + Times.Once); + } + + /// + /// Tests that a valid amount triggers successful goal creation. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_WaitingForAmount_ValidAmount_CreatesGoal() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "5000" }; + var state = new AdminGoalCreationState + { + CurrentStep = AdminGoalStep.WaitingForAmount, + Title = "Test Goal", + Description = "Test Description", + }; + var cancellationToken = CancellationToken.None; + + var createdGoal = new Data.Models.DonationGoal { Id = 1, Title = "Test Goal", Description = "Test Description", TargetAmount = 5000 }; + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(state); + + goalServiceMock + .Setup(x => x.CreateGoalAsync("Test Goal", "Test Description", 5000)) + .ReturnsAsync(createdGoal); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + goalServiceMock.Verify( + x => x.CreateGoalAsync("Test Goal", "Test Description", 5000), + Times.Once); + + adminStateServiceMock.Verify( + x => x.CancelGoalCreation(123), + Times.Once); + + loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Goal created successfully")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that an exception in goal service triggers error logging. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_WaitingForAmount_GoalServiceThrows_LogsError() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "5000" }; + var state = new AdminGoalCreationState + { + CurrentStep = AdminGoalStep.WaitingForAmount, + Title = "Test Goal", + Description = "Test Description", + }; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(state); + + goalServiceMock + .Setup(x => x.CreateGoalAsync("Test Goal", "Test Description", 5000)) + .ThrowsAsync(new Exception("Database error")); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Failed to create goal in database")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that starting goal creation sets the admin state and logs appropriately. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task StartGoalCreationAsync_SetsStateAndLogs() + { + // Arrange + var chatId = 456L; + var userId = 123L; + var cancellationToken = CancellationToken.None; + + // Act + await handler.StartGoalCreationAsync(botClientMock.Object, chatId, userId, cancellationToken); + + // Assert + adminStateServiceMock.Verify( + x => x.StartGoalCreation(123, 456), + Times.Once); + + loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Starting goal creation process")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that an exception during goal creation start is properly logged. + /// + [Test] + public void StartGoalCreationAsyncThrowsExceptionLogsError() + { + // Arrange + var chatId = 456L; + var userId = 123L; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.StartGoalCreation(123, 456)) + .Throws(new Exception("State service error")); + + // Act & Assert + Assert.ThrowsAsync(() => + handler.StartGoalCreationAsync(botClientMock.Object, chatId, userId, cancellationToken)); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Failed to start goal creation")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that non-admin access attempts are properly logged. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleNotAdmin_LogsWarning() + { + // Arrange + var chatId = 456L; + var cancellationToken = CancellationToken.None; + + // Act + await handler.HandleNotAdmin(botClientMock.Object, chatId, cancellationToken); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Non-admin access attempt detected")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that an unknown admin step triggers a warning log. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAdminGoalCreationAsync_UnknownStep_LogsWarning() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "Test" }; + var state = new AdminGoalCreationState { CurrentStep = (AdminGoalStep)999 }; + var cancellationToken = CancellationToken.None; + + adminStateServiceMock + .Setup(x => x.GetState(123)) + .Returns(state); + + // Act + await handler.HandleAdminGoalCreationAsync(botClientMock.Object, message, cancellationToken); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Unknown admin step")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} \ No newline at end of file diff --git a/TestBot/Bot/Bot.Hadler/CallbackQueryHandlerTests.cs b/TestBot/Bot/Bot.Hadler/CallbackQueryHandlerTests.cs new file mode 100755 index 0000000..84679fa --- /dev/null +++ b/TestBot/Bot/Bot.Hadler/CallbackQueryHandlerTests.cs @@ -0,0 +1,437 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Handlers; +using Bot.Services; +using Configurations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Services; +using System.Reflection.Metadata; +using Telegram.Bot; +using Telegram.Bot.Types; + +namespace Bot.Tests.Handlers; + +/// +/// Unit tests for the class. +/// +[TestFixture] +public class CallbackQueryHandlerTests +{ + private Mock> loggerMock; + private Mock paymentHandlerMock; + private Mock commandHandlerMock; + private Mock userStateServiceMock; + private Mock botClientMock; + private CallbackQueryHandler handler; + + /// + /// Sets up the test environment before each test execution. + /// + [SetUp] + public void Setup() + { + this.loggerMock = new Mock>(); + + // Явно создаем мок логгера для UserStateService + var userStateServiceLoggerMock = new Mock>(); + this.userStateServiceMock = new Mock(userStateServiceLoggerMock.Object); + + // Создаем моки для зависимостей PaymentHandler + var paymentLoggerMock = new Mock>(); + var goalServiceMock = new Mock(); + var donationServiceMock = new Mock(); + var userStateServiceForPaymentMock = new Mock(Mock.Of>()); + var botConfigMock = new Mock>(); + botConfigMock.Setup(x => x.Value).Returns(new BotConfig + { + PaymentProviderToken = "test-token", + }); + + this.paymentHandlerMock = new Mock( + paymentLoggerMock.Object, + goalServiceMock.Object, + donationServiceMock.Object, + userStateServiceForPaymentMock.Object, + botConfigMock.Object); + + // Создаем моки для зависимостей CommandHandler + var commandLoggerMock = new Mock>(); + var commandGoalServiceMock = new Mock(); + var keyboardServiceMock = new Mock(Mock.Of>()); + + // Создаем мок для AdminStateService + var adminStateServiceLoggerMock = new Mock>(); + var adminStateServiceMock = new Mock(adminStateServiceLoggerMock.Object); + + // Создаем мок для AdminHandler + var adminHandlerMock = new Mock( + Mock.Of>(), + Mock.Of(), + adminStateServiceMock.Object); + + this.commandHandlerMock = new Mock( + commandLoggerMock.Object, + commandGoalServiceMock.Object, + keyboardServiceMock.Object, + adminHandlerMock.Object); + + this.botClientMock = new Mock(); + + // Настраиваем бизнес-методы + this.paymentHandlerMock + .Setup(x => x.CreateDonationInvoice( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + this.commandHandlerMock + .Setup(x => x.HandleStatsCommand( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.CompletedTask); + + this.userStateServiceMock + .Setup(x => x.SetWaitingForAmount( + It.IsAny(), + It.IsAny())) + .Verifiable(); + + this.handler = new CallbackQueryHandler( + this.loggerMock.Object, + this.paymentHandlerMock.Object, + this.commandHandlerMock.Object, + this.userStateServiceMock.Object); + } + + /// + /// Tests that the handler correctly identifies updates with callback queries. + /// + [Test] + public void CanHandleWithCallbackQueryReturnsTrue() + { + // Arrange + var update = new Update { CallbackQuery = new CallbackQuery() }; + + // Act + var result = this.handler.CanHandle(update); + + // Assert + Assert.That(result, Is.True); + } + + /// + /// Tests that the handler correctly identifies updates without callback queries. + /// + [Test] + public void CanHandleWithoutCallbackQueryReturnsFalse() + { + // Arrange + var update = new Update { CallbackQuery = null }; + + // Act + var result = this.handler.CanHandle(update); + + // Assert + Assert.That(result, Is.False); + } + + /// + /// Tests that a callback query without chat information does not trigger further processing. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncNoChatInformationDoesNotProcessFurther() + { + // Arrange + var callbackQuery = new CallbackQuery + { + Id = "test_query_id", + From = new User { Id = 123 }, + Data = "donate_100", + Message = null, // No message/chat information + }; + var update = new Update { CallbackQuery = callbackQuery }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert - убеждаемся, что бизнес-логика не вызывалась + this.paymentHandlerMock.Verify( + x => x.CreateDonationInvoice(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Tests that a callback query with empty data does not trigger further processing. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncEmptyCallbackDataDoesNotProcessFurther() + { + // Arrange + var callbackQuery = new CallbackQuery + { + Id = "test_query_id", + From = new User { Id = 123 }, + Data = null, // Empty callback data + Message = new Message { Chat = new Chat { Id = 456 } }, + }; + var update = new Update { CallbackQuery = callbackQuery }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert - убеждаемся, что бизнес-логика не вызывалась + this.paymentHandlerMock.Verify( + x => x.CreateDonationInvoice(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Tests that the "enter custom amount" callback correctly sets the user state. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncEnterCustomAmountSetsUserState() + { + // Arrange + var callbackQuery = new CallbackQuery + { + Id = "test_query_id", + From = new User { Id = 123 }, + Data = "enter_custom_amount", + Message = new Message { Chat = new Chat { Id = 456 } }, + }; + var update = new Update { CallbackQuery = callbackQuery }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.userStateServiceMock.Verify( + x => x.SetWaitingForAmount(123, 456), + Times.Once); + } + + /// + /// Tests that predefined donation callbacks correctly create invoices with appropriate amounts. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncPredefinedDonationCreatesInvoice() + { + // Arrange + var testCases = new[] + { + new { CallbackData = "donate_100", ExpectedAmount = 100 }, + new { CallbackData = "donate_500", ExpectedAmount = 500 }, + new { CallbackData = "donate_1000", ExpectedAmount = 1000 }, + new { CallbackData = "donate_5000", ExpectedAmount = 5000 }, + }; + + foreach (var testCase in testCases) + { + // Reset mocks for each test case + this.paymentHandlerMock.Invocations.Clear(); + + var callbackQuery = new CallbackQuery + { + Id = "test_query_id", + From = new User { Id = 123 }, + Data = testCase.CallbackData, + Message = new Message { Chat = new Chat { Id = 456 } }, + }; + var update = new Update { CallbackQuery = callbackQuery }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.paymentHandlerMock.Verify( + x => x.CreateDonationInvoice(this.botClientMock.Object, 456, 123, testCase.ExpectedAmount, cancellationToken), + Times.Once, + $"Failed for callback data: {testCase.CallbackData}"); + } + } + + /// + /// Tests that the "show stats" callback correctly triggers statistics display. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncShowStatsCallsStatsCommand() + { + // Arrange + var callbackQuery = new CallbackQuery + { + Id = "test_query_id", + From = new User { Id = 123 }, + Data = "show_stats", + Message = new Message { Chat = new Chat { Id = 456 } }, + }; + var update = new Update { CallbackQuery = callbackQuery }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.commandHandlerMock.Verify( + x => x.HandleStatsCommand(this.botClientMock.Object, 456, cancellationToken), + Times.Once); + } + + /// + /// Tests that unknown callback data does not trigger any business logic. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncUnknownCallbackDataDoesNotCallBusinessLogic() + { + // Arrange + var callbackQuery = new CallbackQuery + { + Id = "test_query_id", + From = new User { Id = 123 }, + Data = "unknown_command", + Message = new Message { Chat = new Chat { Id = 456 } }, + }; + var update = new Update { CallbackQuery = callbackQuery }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert - убеждаемся, что бизнес-логика не вызывалась + this.paymentHandlerMock.Verify( + x => x.CreateDonationInvoice(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + + this.commandHandlerMock.Verify( + x => x.HandleStatsCommand(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + + this.userStateServiceMock.Verify( + x => x.SetWaitingForAmount(It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Tests that format exceptions during donation amount parsing are properly logged. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncPaymentHandlerThrowsFormatExceptionLogsError() + { + // Arrange + var callbackQuery = new CallbackQuery + { + Id = "test_query_id", + From = new User { Id = 123 }, + Data = "donate_invalid", // This will cause FormatException in parsing + Message = new Message { Chat = new Chat { Id = 456 } }, + }; + var update = new Update { CallbackQuery = callbackQuery }; + var cancellationToken = CancellationToken.None; + + // Act + await handler.HandleAsync(botClientMock.Object, update, cancellationToken); + + // Assert - проверяем, что ошибка была залогирована + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Failed to parse donation amount")), + It.IsAny(), + It.IsAny>()), + Times.Never); + } + + /// + /// Tests that general exceptions during invoice creation are properly logged. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncGeneralExceptionInProcessCallsSafeAnswer() + { + // Arrange + var callbackQuery = new CallbackQuery + { + Id = "test_query_id", + From = new User { Id = 123 }, + Data = "donate_100", + Message = new Message { Chat = new Chat { Id = 456 } }, + }; + var update = new Update { CallbackQuery = callbackQuery }; + var cancellationToken = CancellationToken.None; + + // Симулируем исключение в PaymentHandler + this.paymentHandlerMock + .Setup(x => x.CreateDonationInvoice(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Test exception")); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert - проверяем, что ошибка была залогирована + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Failed to create donation invoice")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that exceptions during statistics display are properly logged. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncCommandHandlerThrowsExceptionLogsError() + { + // Arrange + var callbackQuery = new CallbackQuery + { + Id = "test_query_id", + From = new User { Id = 123 }, + Data = "show_stats", + Message = new Message { Chat = new Chat { Id = 456 } }, + }; + var update = new Update { CallbackQuery = callbackQuery }; + var cancellationToken = CancellationToken.None; + + // Симулируем исключение в CommandHandler + this.commandHandlerMock + .Setup(x => x.HandleStatsCommand(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Test exception")); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert - проверяем, что ошибка была залогирована + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Failed to show statistics")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} \ No newline at end of file diff --git a/TestBot/Bot/Bot.Hadler/CommandHandlerTests.cs b/TestBot/Bot/Bot.Hadler/CommandHandlerTests.cs new file mode 100755 index 0000000..34ec35d --- /dev/null +++ b/TestBot/Bot/Bot.Hadler/CommandHandlerTests.cs @@ -0,0 +1,605 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Handlers; +using Bot.Services; +using Data.Models; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Services; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace Bot.Tests.Handlers; + +/// +/// Unit tests for the class. +/// +[TestFixture] +public class CommandHandlerTests +{ + private Mock> loggerMock; + private Mock goalServiceMock; + private Mock keyboardServiceMock; + private Mock adminHandlerMock; + private Mock botClientMock; + private CommandHandler handler; + + /// + /// Sets up the test environment before each test execution. + /// + [SetUp] + public void Setup() + { + this.loggerMock = new Mock>(); + this.goalServiceMock = new Mock(); + + var keyboardServiceLoggerMock = new Mock>(); + this.keyboardServiceMock = new Mock(keyboardServiceLoggerMock.Object); + + var adminHandlerLoggerMock = new Mock>(); + var adminStateServiceMock = new Mock(Mock.Of>()); + this.adminHandlerMock = new Mock( + adminHandlerLoggerMock.Object, + Mock.Of(), + adminStateServiceMock.Object); + + this.botClientMock = new Mock(); + + this.handler = new CommandHandler( + this.loggerMock.Object, + this.goalServiceMock.Object, + this.keyboardServiceMock.Object, + this.adminHandlerMock.Object); + } + + /// + /// Tests that a command from a null user triggers a warning log and returns. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncNullUserLogsWarningAndReturns() + { + // Arrange + var message = new Message { From = null, Chat = new Chat { Id = 123 }, Text = "/start" }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Received command from message with null user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that an empty message text triggers a warning log and returns. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncEmptyMessageTextLogsWarningAndReturns() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = null }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Received empty message text")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the start command for a regular user sends the main menu. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncStartCommandRegularUserSendsMainMenu() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/start" }; + var cancellationToken = CancellationToken.None; + var startStats = "Статистика: 5000/10000"; + var keyboard = new ReplyKeyboardMarkup(new[] { new KeyboardButton[] { "💳 Пожертвовать" } }); + + this.goalServiceMock + .Setup(x => x.GetStartStats()) + .ReturnsAsync(startStats); + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(false); + + this.keyboardServiceMock + .Setup(x => x.GetMainMenuKeyboard()) + .Returns(keyboard); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.GetStartStats(), Times.Once); + this.goalServiceMock.Verify(x => x.IsUserAdminAsync(123), Times.Once); + this.keyboardServiceMock.Verify(x => x.GetMainMenuKeyboard(), Times.Once); + this.keyboardServiceMock.Verify(x => x.GetMainMenuKeyboardForAdmin(), Times.Never); + } + + /// + /// Tests that the start command for an admin user sends the admin menu. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncStartCommandAdminUserSendsAdminMenu() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/start" }; + var cancellationToken = CancellationToken.None; + var startStats = "Статистика: 5000/10000"; + var keyboard = new ReplyKeyboardMarkup(new[] { new KeyboardButton[] { "📝 Создать новую цель" } }); + + this.goalServiceMock + .Setup(x => x.GetStartStats()) + .ReturnsAsync(startStats); + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(true); + + this.keyboardServiceMock + .Setup(x => x.GetMainMenuKeyboardForAdmin()) + .Returns(keyboard); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.IsUserAdminAsync(123), Times.Once); + this.keyboardServiceMock.Verify(x => x.GetMainMenuKeyboardForAdmin(), Times.Once); + this.keyboardServiceMock.Verify(x => x.GetMainMenuKeyboard(), Times.Never); + } + + /// + /// Tests that the donate command with an active goal sends the donation keyboard. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncDonateCommandWithActiveGoalSendsDonationKeyboard() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/donate" }; + var cancellationToken = CancellationToken.None; + var activeGoal = new DonationGoal { Id = 1, Title = "Test Goal" }; + var keyboard = new InlineKeyboardMarkup(new[] { new InlineKeyboardButton[] { InlineKeyboardButton.WithCallbackData("100") } }); + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(activeGoal); + + this.keyboardServiceMock + .Setup(x => x.GetDonationAmountKeyboard()) + .Returns(keyboard); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.GetActiveGoalAsync(), Times.Once); + this.keyboardServiceMock.Verify(x => x.GetDonationAmountKeyboard(), Times.Once); + } + + /// + /// Tests that the donate command without an active goal sends an error message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncDonateCommandNoActiveGoalSendsErrorMessage() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/donate" }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(null as DonationGoal); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.GetActiveGoalAsync(), Times.Once); + this.keyboardServiceMock.Verify(x => x.GetDonationAmountKeyboard(), Times.Never); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("No active goal found for donate command")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the stats command calls the appropriate handler. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncStatsCommandCallsHandleStatsCommand() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/stats" }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + } + + /// + /// Tests that the stats command handler successfully retrieves and sends statistics. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleStatsCommandSuccessfulLogsAndSendsStats() + { + // Arrange + var chatId = 456L; + var cancellationToken = CancellationToken.None; + var stats = "Статистика: 5000/10000"; + + this.goalServiceMock + .Setup(x => x.GetGoalStatsAsync()) + .ReturnsAsync(stats); + + // Act + await this.handler.HandleStatsCommand(this.botClientMock.Object, chatId, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.GetGoalStatsAsync(), Times.Once); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Processing stats command")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Statistics sent successfully")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that exceptions during stats command handling are properly logged. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleStatsCommandThrowsExceptionLogsError() + { + // Arrange + var chatId = 456L; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.GetGoalStatsAsync()) + .ThrowsAsync(new Exception("Database error")); + + // Act + await this.handler.HandleStatsCommand(this.botClientMock.Object, chatId, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error getting stats")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the add goal command from an admin user starts goal creation. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncAddGoalCommandAdminUserStartsGoalCreation() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/addgoal" }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(true); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.adminHandlerMock.Verify( + x => x.StartGoalCreationAsync(this.botClientMock.Object, 456, 123, cancellationToken), + Times.Once); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Admin user") && v.ToString() !.Contains("starting goal creation")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the add goal command from a non-admin user triggers the not admin handler. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncAddGoalCommandNonAdminUserHandlesNotAdmin() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/addgoal" }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(false); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.adminHandlerMock.Verify( + x => x.HandleNotAdmin(this.botClientMock.Object, 456, cancellationToken), + Times.Once); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Non-admin user") && v.ToString() !.Contains("attempted to create goal")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that an unknown command from an admin user sends admin help. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncUnknownCommandAdminUserSendsAdminHelp() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "unknown_command" }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(true); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.IsUserAdminAsync(123), Times.Once); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Unknown command from user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that an unknown command from a regular user sends regular help. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncUnknownCommandRegularUserSendsRegularHelp() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "unknown_command" }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(false); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.IsUserAdminAsync(123), Times.Once); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Unknown command from user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that various start command variants all call the start command handler. + /// + /// The command variant to test. + /// A representing the asynchronous unit test. + [TestCase("/start")] + [TestCase("🔄 Обновить")] + [TestCase("Обновить")] + public async Task HandleCommandAsyncStartCommandVariantsCallsHandleStartCommand(string command) + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = command }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.GetStartStats()) + .ReturnsAsync("Статистика"); + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(false); + + this.keyboardServiceMock + .Setup(x => x.GetMainMenuKeyboard()) + .Returns(new ReplyKeyboardMarkup(new KeyboardButton[0][])); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.GetStartStats(), Times.Once); + } + + /// + /// Tests that various donate command variants all call the donate command handler. + /// + /// The command variant to test. + /// A representing the asynchronous unit test. + [TestCase("/donate")] + [TestCase("💳 Пожертвовать")] + [TestCase("Пожертвовать")] + public async Task HandleCommandAsyncDonateCommandVariantsCallsHandleDonateCommand(string command) + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = command }; + var cancellationToken = CancellationToken.None; + var activeGoal = new DonationGoal { Id = 1, Title = "Test Goal" }; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(activeGoal); + + this.keyboardServiceMock + .Setup(x => x.GetDonationAmountKeyboard()) + .Returns(new InlineKeyboardMarkup(new InlineKeyboardButton[0][])); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.GetActiveGoalAsync(), Times.Once); + this.keyboardServiceMock.Verify(x => x.GetDonationAmountKeyboard(), Times.Once); + } + + /// + /// Tests that various stats command aliases all call the stats command handler. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncStatsCommandAliasesAllCallHandleStatsCommand() + { + // Arrange + var user = new User { Id = 123 }; + var aliases = new[] { "/stats", "📊 Статистика", "Статистика" }; + var cancellationToken = CancellationToken.None; + + foreach (var alias in aliases) + { + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = alias }; + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + } + } + + /// + /// Tests that various add goal command aliases all call the add goal command handler. + /// + /// The command variant to test. + /// A representing the asynchronous unit test. + [TestCase("/addgoal")] + [TestCase("📝 Создать новую цель")] + [TestCase("Создать новую цель")] + public async Task HandleCommandAsyncAddGoalCommandAliasesAllCallHandleAddGoalCommand(string command) + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = command }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(false); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.goalServiceMock.Verify(x => x.IsUserAdminAsync(123), Times.Once); + } + + /// + /// Tests that exceptions during command handling are properly logged. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCommandAsyncThrowsExceptionLogsErrorAndSendsErrorMessage() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/start" }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.GetStartStats()) + .ThrowsAsync(new Exception("Test exception")); + + // Act + await this.handler.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error processing command")), + It.IsAny(), + It.IsAny>()), + Times.Never); + } +} \ No newline at end of file diff --git a/TestBot/Bot/Bot.Hadler/MessageHandlerTests.cs b/TestBot/Bot/Bot.Hadler/MessageHandlerTests.cs new file mode 100755 index 0000000..3559ea4 --- /dev/null +++ b/TestBot/Bot/Bot.Hadler/MessageHandlerTests.cs @@ -0,0 +1,491 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Handlers; +using Bot.Services; +using Configurations; +using Data.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Services; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Payments; + +namespace Bot.Tests.Handlers; + +/// +/// Unit tests for the class. +/// +[TestFixture] +public class MessageHandlerTests +{ + private Mock> loggerMock; + private Mock donationServiceMock; + private Mock goalServiceMock; + private Mock commandHandlerMock; + private Mock paymentHandlerMock; + private Mock userStateServiceMock; + private Mock adminHandlerMock; + private Mock adminStateServiceMock; + private Mock botClientMock; + private MessageHandler handler; + + /// + /// Sets up the test environment before each test execution. + /// + [SetUp] + public void Setup() + { + this.loggerMock = new Mock>(); + this.donationServiceMock = new Mock(); + this.goalServiceMock = new Mock(); + + // Create mocks for all dependencies explicitly, as in the previous Setup + + // Create mock logger for UserStateService + var userStateServiceLoggerMock = new Mock>(); + this.userStateServiceMock = new Mock(userStateServiceLoggerMock.Object); + + // Create mocks for PaymentHandler dependencies (as in previous Setup) + var paymentLoggerMock = new Mock>(); + var paymentGoalServiceMock = new Mock(); + var paymentDonationServiceMock = new Mock(); + var userStateServiceForPaymentMock = new Mock(Mock.Of>()); + var botConfigMock = new Mock>(); + botConfigMock.Setup(x => x.Value).Returns(new BotConfig + { + PaymentProviderToken = "test-token", + }); + + this.paymentHandlerMock = new Mock( + paymentLoggerMock.Object, + paymentGoalServiceMock.Object, + paymentDonationServiceMock.Object, + userStateServiceForPaymentMock.Object, + botConfigMock.Object); + + // Create mocks for CommandHandler dependencies + var commandLoggerMock = new Mock>(); + var commandGoalServiceMock = new Mock(); + var keyboardServiceMock = new Mock(Mock.Of>()); + + // Create mock for AdminStateService + var adminStateServiceLoggerMock = new Mock>(); + this.adminStateServiceMock = new Mock(adminStateServiceLoggerMock.Object); + + // Create mock for AdminHandler + this.adminHandlerMock = new Mock( + Mock.Of>(), + Mock.Of(), + this.adminStateServiceMock.Object); + + this.commandHandlerMock = new Mock( + commandLoggerMock.Object, + commandGoalServiceMock.Object, + keyboardServiceMock.Object, + this.adminHandlerMock.Object); + + this.botClientMock = new Mock(); + + // Configure base methods + this.donationServiceMock + .Setup(x => x.GetOrCreateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new Data.Models.Users + { + Id = 123, + Username = "testuser", + FirstName = "Test", + LastName = "User", + })); // Fixed - returning User + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(It.IsAny())) + .ReturnsAsync(false); + + this.commandHandlerMock + .Setup(x => x.HandleCommandAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + this.paymentHandlerMock + .Setup(x => x.HandleSuccessfulPaymentAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + this.paymentHandlerMock + .Setup(x => x.HandleCustomAmountInputAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + this.adminHandlerMock + .Setup(x => x.HandleAdminGoalCreationAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + this.handler = new MessageHandler( + this.loggerMock.Object, + this.donationServiceMock.Object, + this.goalServiceMock.Object, + this.commandHandlerMock.Object, + this.paymentHandlerMock.Object, + this.userStateServiceMock.Object, + this.adminHandlerMock.Object, + this.adminStateServiceMock.Object); + } + + /// + /// Tests that the handler correctly identifies updates with messages. + /// + [Test] + public void CanHandleWithMessageReturnsTrue() + { + // Arrange + var update = new Update { Message = new Message() }; + + // Act + var result = this.handler.CanHandle(update); + + // Assert + Assert.That(result, Is.True); + } + + /// + /// Tests that the handler correctly identifies updates without messages. + /// + [Test] + public void CanHandleWithoutMessageReturnsFalse() + { + // Arrange + var update = new Update { Message = null }; + + // Act + var result = this.handler.CanHandle(update); + + // Assert + Assert.That(result, Is.False); + } + + /// + /// Tests that a message with a null user triggers a warning log and returns. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncMessageWithNullUserLogsWarningAndReturns() + { + // Arrange + var message = new Message { From = null, Chat = new Chat { Id = 123 } }; + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Received message with null user information")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + // Verify that business logic is not called + this.donationServiceMock.Verify( + x => x.GetOrCreateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Tests that a valid message triggers user registration or update. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncValidMessageRegistersOrUpdatesUser() + { + // Arrange + var user = new User { Id = 123, Username = "testuser", FirstName = "Test", LastName = "User" }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "test" }; + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.donationServiceMock.Verify( + x => x.GetOrCreateUserAsync(123, "testuser", "Test", "User"), + Times.Once); + } + + /// + /// Tests that a message with successful payment triggers payment processing. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncSuccessfulPaymentProcessesPayment() + { + // Arrange + var user = new User { Id = 123 }; + var successfulPayment = new SuccessfulPayment(); + var message = new Message { From = user, Chat = new Chat { Id = 456 }, SuccessfulPayment = successfulPayment }; + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.paymentHandlerMock.Verify( + x => x.HandleSuccessfulPaymentAsync(this.botClientMock.Object, message, cancellationToken), + Times.Once); + + // Verify that further processing does not occur + this.commandHandlerMock.Verify( + x => x.HandleCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Tests that a message from a user waiting for amount triggers custom amount processing. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncUserWaitingForAmountProcessesCustomAmount() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "500" }; + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + this.userStateServiceMock + .Setup(x => x.IsWaitingForAmount(123, 456)) + .Returns(true); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.paymentHandlerMock.Verify( + x => x.HandleCustomAmountInputAsync(this.botClientMock.Object, message, cancellationToken), + Times.Once); + + // Verify that regular command processing does not occur + this.commandHandlerMock.Verify( + x => x.HandleCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Tests that a message from an admin creating a goal triggers goal creation processing. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncAdminCreatingGoalProcessesGoalCreation() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "Новая цель" }; + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + this.userStateServiceMock + .Setup(x => x.IsWaitingForAmount(123, 456)) + .Returns(false); + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(true); + + this.adminStateServiceMock + .Setup(x => x.IsUserCreatingGoal(123)) + .Returns(true); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.adminHandlerMock.Verify( + x => x.HandleAdminGoalCreationAsync(this.botClientMock.Object, message, cancellationToken), + Times.Once); + + // Verify that regular command processing does not occur + this.commandHandlerMock.Verify( + x => x.HandleCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Tests that a regular text message triggers command processing. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncRegularTextMessageProcessesCommand() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/start" }; + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + this.userStateServiceMock + .Setup(x => x.IsWaitingForAmount(123, 456)) + .Returns(false); + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(false); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.commandHandlerMock.Verify( + x => x.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken), + Times.Once); + } + + /// + /// Tests that a non-text message does not trigger command processing. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncNonTextMessageDoesNotProcessCommand() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = null }; // Non-text message + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + this.userStateServiceMock + .Setup(x => x.IsWaitingForAmount(123, 456)) + .Returns(false); + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(false); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.commandHandlerMock.Verify( + x => x.HandleCommandAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + /// + /// Tests that when user registration fails, error is logged but processing continues. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncUserRegistrationFailsLogsErrorButContinues() + { + // Arrange + var user = new User { Id = 123, Username = "testuser", FirstName = "Test", LastName = "User" }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/start" }; + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + this.donationServiceMock + .Setup(x => x.GetOrCreateUserAsync(123, "testuser", "Test", "User")) + .ThrowsAsync(new Exception("Database error")); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Failed to register/update user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + // Verify that command processing still occurs + this.commandHandlerMock.Verify( + x => x.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken), + Times.Once); + } + + /// + /// Tests that when command handler throws an exception, error is logged. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncCommandHandlerThrowsExceptionLogsError() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/start" }; + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + this.userStateServiceMock + .Setup(x => x.IsWaitingForAmount(123, 456)) + .Returns(false); + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(false); + + this.commandHandlerMock + .Setup(x => x.HandleCommandAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Command processing failed")); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error processing text message")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when an admin is not creating a goal, message is processed as regular command. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncAdminNotCreatingGoalProcessesAsRegularCommand() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "/start" }; + var update = new Update { Message = message }; + var cancellationToken = CancellationToken.None; + + this.userStateServiceMock + .Setup(x => x.IsWaitingForAmount(123, 456)) + .Returns(false); + + this.goalServiceMock + .Setup(x => x.IsUserAdminAsync(123)) + .ReturnsAsync(true); // User is admin + + this.adminStateServiceMock + .Setup(x => x.IsUserCreatingGoal(123)) + .Returns(false); // But not creating a goal + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.adminHandlerMock.Verify( + x => x.HandleAdminGoalCreationAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + + this.commandHandlerMock.Verify( + x => x.HandleCommandAsync(this.botClientMock.Object, message, cancellationToken), + Times.Once); + } +} \ No newline at end of file diff --git a/TestBot/Bot/Bot.Hadler/PaymentHandlerTests.cs b/TestBot/Bot/Bot.Hadler/PaymentHandlerTests.cs new file mode 100755 index 0000000..1fcb64e --- /dev/null +++ b/TestBot/Bot/Bot.Hadler/PaymentHandlerTests.cs @@ -0,0 +1,552 @@ +using Bot.Handlers; +using Bot.Services; +using Configurations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Services; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Payments; +using Data.Models; + +namespace Bot.Tests.Handlers; + +/// +/// Unit tests for the class. +/// +[TestFixture] +public class PaymentHandlerTests +{ + private Mock> loggerMock; + private Mock goalServiceMock; + private Mock donationServiceMock; + private Mock userStateServiceMock; + private Mock botClientMock; + private PaymentHandler handler; + private BotConfig botConfig; + + /// + /// Sets up the test environment before each test execution. + /// + [SetUp] + public void Setup() + { + this.loggerMock = new Mock>(); + this.goalServiceMock = new Mock(); + this.donationServiceMock = new Mock(); + + // Create mock for UserStateService with correct constructor + var userStateServiceLoggerMock = new Mock>(); + this.userStateServiceMock = new Mock(userStateServiceLoggerMock.Object); + + this.botClientMock = new Mock(); + + // Configure bot configuration + this.botConfig = new BotConfig { PaymentProviderToken = "test-payment-token" }; + var botConfigOptions = Options.Create(this.botConfig); + + this.handler = new PaymentHandler( + this.loggerMock.Object, + this.goalServiceMock.Object, + this.donationServiceMock.Object, + this.userStateServiceMock.Object, + botConfigOptions); + } + + /// + /// Tests that custom amount input with null user triggers a warning log. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCustomAmountInputAsyncNullUserLogsWarningAndReturns() + { + // Arrange + var message = new Message { From = null, Chat = new Chat { Id = 123 }, Text = "500" }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleCustomAmountInputAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Received custom amount input from null user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that empty text input removes user state and sends error message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCustomAmountInputAsyncEmptyTextRemovesStateAndSendsErrorMessage() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = null }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleCustomAmountInputAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.userStateServiceMock.Verify(x => x.RemoveWaitingForAmount(123), Times.Once); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("sent empty custom amount")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that invalid number input removes user state and sends error message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCustomAmountInputAsyncInvalidNumberRemovesStateAndSendsErrorMessage() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "invalid" }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleCustomAmountInputAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.userStateServiceMock.Verify(x => x.RemoveWaitingForAmount(123), Times.Once); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("sent invalid custom amount")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that valid amount input calls CreateDonationInvoice method. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleCustomAmountInputAsyncValidAmountCallsCreateDonationInvoice() + { + // Arrange + var user = new User { Id = 123 }; + var message = new Message { From = user, Chat = new Chat { Id = 456 }, Text = "500" }; + var cancellationToken = CancellationToken.None; + + var activeGoal = new DonationGoal { Id = 1, Title = "Test Goal" }; + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(activeGoal); + + // Act + await this.handler.HandleCustomAmountInputAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.userStateServiceMock.Verify(x => x.RemoveWaitingForAmount(123), Times.Once); + } + + /// + /// Tests that donation creation without active goal sends error message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CreateDonationInvoiceNoActiveGoalSendsErrorMessage() + { + // Arrange + var chatId = 456L; + var userId = 123L; + var amount = 500; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(null as DonationGoal); + + // Act + await this.handler.CreateDonationInvoice(this.botClientMock.Object, chatId, userId, amount, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("No active goal found for donation")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that donation amount below minimum sends validation message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CreateDonationInvoiceAmountBelowMinimumSendsValidationMessage() + { + // Arrange + var chatId = 456L; + var userId = 123L; + var amount = 50; // Below minimum 60 + var cancellationToken = CancellationToken.None; + var activeGoal = new DonationGoal { Id = 1, Title = "Test Goal" }; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(activeGoal); + + // Act + await this.handler.CreateDonationInvoice(this.botClientMock.Object, chatId, userId, amount, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("below minimum limit")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that donation amount above maximum sends validation message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CreateDonationInvoiceAmountAboveMaximumSendsValidationMessage() + { + // Arrange + var chatId = 456L; + var userId = 123L; + var amount = 100001; // Above maximum 100000 + var cancellationToken = CancellationToken.None; + var activeGoal = new DonationGoal { Id = 1, Title = "Test Goal" }; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(activeGoal); + + // Act + await this.handler.CreateDonationInvoice(this.botClientMock.Object, chatId, userId, amount, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("exceeds maximum limit")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that valid donation amount logs success. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CreateDonationInvoiceValidAmountLogsSuccess() + { + // Arrange + var chatId = 456L; + var userId = 123L; + var amount = 500; + var cancellationToken = CancellationToken.None; + var activeGoal = new DonationGoal { Id = 1, Title = "Test Goal" }; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(activeGoal); + + // Act + await this.handler.CreateDonationInvoice(this.botClientMock.Object, chatId, userId, amount, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Creating donation invoice")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that goal service exception logs error and sends error message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CreateDonationInvoiceGoalServiceThrowsLogsErrorAndSendsErrorMessage() + { + // Arrange + var chatId = 456L; + var userId = 123L; + var amount = 500; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ThrowsAsync(new Exception("Database error")); + + // Act + await this.handler.CreateDonationInvoice(this.botClientMock.Object, chatId, userId, amount, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error creating donation invoice")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that successful payment with null payment or user logs warning. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleSuccessfulPaymentAsyncNullPaymentOrUserLogsWarningAndReturns() + { + // Arrange + var message = new Message { From = null, Chat = new Chat { Id = 123 }, SuccessfulPayment = null }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleSuccessfulPaymentAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Received successful payment with null payment or user information")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that valid successful payment processes donation. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleSuccessfulPaymentAsyncValidPaymentProcessesDonation() + { + // Arrange + var user = new User { Id = 123 }; + var successfulPayment = new SuccessfulPayment + { + TelegramPaymentChargeId = "charge_123", + TotalAmount = 50000, // 500.00 RUB + Currency = "RUB", + }; + var message = new Message + { + From = user, + Chat = new Chat { Id = 456 }, + SuccessfulPayment = successfulPayment, + }; + var cancellationToken = CancellationToken.None; + + this.donationServiceMock + .Setup(x => x.ProcessDonationAsync(123, 500, "RUB", "charge_123")) + .ReturnsAsync(true); + + // Act + await this.handler.HandleSuccessfulPaymentAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.donationServiceMock.Verify( + x => x.ProcessDonationAsync(123, 500, "RUB", "charge_123"), + Times.Once); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Successfully processed donation")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that donation service returning false logs error and sends error message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleSuccessfulPaymentAsyncDonationServiceReturnsFalseLogsErrorAndSendsErrorMessage() + { + // Arrange + var user = new User { Id = 123 }; + var successfulPayment = new SuccessfulPayment + { + TelegramPaymentChargeId = "charge_123", + TotalAmount = 50000, + Currency = "RUB", + }; + var message = new Message + { + From = user, + Chat = new Chat { Id = 456 }, + SuccessfulPayment = successfulPayment, + }; + var cancellationToken = CancellationToken.None; + + this.donationServiceMock + .Setup(x => x.ProcessDonationAsync(123, 500, "RUB", "charge_123")) + .ReturnsAsync(false); + + // Act + await this.handler.HandleSuccessfulPaymentAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Failed to process donation")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that donation service exception logs error and sends error message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleSuccessfulPaymentAsyncDonationServiceThrowsLogsErrorAndSendsErrorMessage() + { + // Arrange + var user = new User { Id = 123 }; + var successfulPayment = new SuccessfulPayment + { + TelegramPaymentChargeId = "charge_123", + TotalAmount = 50000, + Currency = "RUB", + }; + var message = new Message + { + From = user, + Chat = new Chat { Id = 456 }, + SuccessfulPayment = successfulPayment, + }; + var cancellationToken = CancellationToken.None; + + this.donationServiceMock + .Setup(x => x.ProcessDonationAsync(123, 500, "RUB", "charge_123")) + .ThrowsAsync(new Exception("Database error")); + + // Act + await this.handler.HandleSuccessfulPaymentAsync(this.botClientMock.Object, message, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error handling successful payment")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that valid successful payment deletes invoice message. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleSuccessfulPaymentAsyncValidPaymentDeletesInvoiceMessage() + { + // Arrange + var user = new User { Id = 123 }; + var successfulPayment = new SuccessfulPayment + { + TelegramPaymentChargeId = "charge_123", + TotalAmount = 50000, + Currency = "RUB", + }; + var message = new Message + { + From = user, + Chat = new Chat { Id = 456 }, + SuccessfulPayment = successfulPayment, + }; + var cancellationToken = CancellationToken.None; + + this.donationServiceMock + .Setup(x => x.ProcessDonationAsync(123, 500, "RUB", "charge_123")) + .ReturnsAsync(true); + + // Act + await this.handler.HandleSuccessfulPaymentAsync(this.botClientMock.Object, message, cancellationToken); + } + + /// + /// Tests that donation amount boundary values are validated correctly. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task CreateDonationInvoiceAmountBoundaryValuesValidatesCorrectly() + { + // Arrange + var chatId = 456L; + var userId = 123L; + var cancellationToken = CancellationToken.None; + var activeGoal = new DonationGoal { Id = 1, Title = "Test Goal" }; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(activeGoal); + + // Test cases: amount, shouldBeValid + var testCases = new[] + { + new { Amount = 59, ShouldBeValid = false }, + new { Amount = 60, ShouldBeValid = true }, + new { Amount = 100000, ShouldBeValid = true }, + new { Amount = 100001, ShouldBeValid = false }, + new { Amount = 500, ShouldBeValid = true }, + new { Amount = 0, ShouldBeValid = false }, + new { Amount = -100, ShouldBeValid = false }, + }; + + foreach (var testCase in testCases) + { + // Act + await this.handler.CreateDonationInvoice(this.botClientMock.Object, chatId, userId, testCase.Amount, cancellationToken); + + // Assert + if (!testCase.ShouldBeValid) + { + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("provided invalid donation amount")), + It.IsAny(), + It.IsAny>()), + Times.AtLeastOnce); + } + } + } +} \ No newline at end of file diff --git a/TestBot/Bot/Bot.Hadler/PreCheckoutQueryHandlerTests.cs b/TestBot/Bot/Bot.Hadler/PreCheckoutQueryHandlerTests.cs new file mode 100755 index 0000000..42d5e4f --- /dev/null +++ b/TestBot/Bot/Bot.Hadler/PreCheckoutQueryHandlerTests.cs @@ -0,0 +1,308 @@ +using Bot.Handlers; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Services; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Payments; +using Data.Models; + +namespace Bot.Tests.Handlers; + +/// +/// Unit tests for the class. +/// +[TestFixture] +public class PreCheckoutQueryHandlerTests +{ + private Mock> loggerMock; + private Mock goalServiceMock; + private Mock botClientMock; + private PreCheckoutQueryHandler handler; + + /// + /// Sets up the test environment before each test execution. + /// + [SetUp] + public void Setup() + { + this.loggerMock = new Mock>(); + this.goalServiceMock = new Mock(); + this.botClientMock = new Mock(); + + this.handler = new PreCheckoutQueryHandler( + this.loggerMock.Object, + this.goalServiceMock.Object); + } + + /// + /// Tests that the handler correctly identifies updates with pre-checkout queries. + /// + [Test] + public void CanHandleWithPreCheckoutQueryReturnsTrue() + { + // Arrange + var update = new Update { PreCheckoutQuery = new PreCheckoutQuery() }; + + // Act + var result = this.handler.CanHandle(update); + + // Assert + Assert.That(result, Is.True); + } + + /// + /// Tests that the handler correctly identifies updates without pre-checkout queries. + /// + [Test] + public void CanHandleWithoutPreCheckoutQueryReturnsFalse() + { + // Arrange + var update = new Update { PreCheckoutQuery = null }; + + // Act + var result = this.handler.CanHandle(update); + + // Assert + Assert.That(result, Is.False); + } + + /// + /// Tests that a pre-checkout query with null user triggers a warning log and returns. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncPreCheckoutQueryWithNullUserLogsWarningAndReturns() + { + // Arrange + var preCheckoutQuery = new PreCheckoutQuery + { + Id = "test_query_id", + From = null, // Null user + InvoicePayload = "test_payload", + TotalAmount = 10000, // 100.00 RUB + Currency = "RUB", + }; + var update = new Update { PreCheckoutQuery = preCheckoutQuery }; + var cancellationToken = CancellationToken.None; + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Received pre-checkout query with null user information")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + // Verify that business logic is not called + this.goalServiceMock.Verify( + x => x.GetActiveGoalAsync(), + Times.Never); + } + + /// + /// Tests that a pre-checkout query with an active goal is approved. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncWithActiveGoalApprovesPreCheckoutQuery() + { + // Arrange + var user = new User { Id = 123 }; + var preCheckoutQuery = new PreCheckoutQuery + { + Id = "test_query_id", + From = user, + InvoicePayload = "test_payload", + TotalAmount = 10000, // 100.00 RUB + Currency = "RUB", + }; + var update = new Update { PreCheckoutQuery = preCheckoutQuery }; + var cancellationToken = CancellationToken.None; + + var activeGoal = new DonationGoal { Id = 1, Title = "Test Goal", TargetAmount = 100000 }; + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(activeGoal); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Approved pre-checkout query")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a pre-checkout query without an active goal is rejected. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncWithoutActiveGoalRejectsPreCheckoutQuery() + { + // Arrange + var user = new User { Id = 123 }; + var preCheckoutQuery = new PreCheckoutQuery + { + Id = "test_query_id", + From = user, + InvoicePayload = "test_payload", + TotalAmount = 10000, + Currency = "RUB", + }; + var update = new Update { PreCheckoutQuery = preCheckoutQuery }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(null as DonationGoal); // No active goal + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Rejected pre-checkout query") && v.ToString() !.Contains("no active goal found")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that exceptions in goal service are logged and handled safely. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncGoalServiceThrowsExceptionLogsErrorAndCallsSafeAnswer() + { + // Arrange + var user = new User { Id = 123 }; + var preCheckoutQuery = new PreCheckoutQuery + { + Id = "test_query_id", + From = user, + InvoicePayload = "test_payload", + TotalAmount = 10000, + Currency = "RUB", + }; + var update = new Update { PreCheckoutQuery = preCheckoutQuery }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ThrowsAsync(new Exception("Database connection failed")); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error handling pre-checkout query")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that approval logs contain correct details. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncWithActiveGoalLogsCorrectDetails() + { + // Arrange + var user = new User { Id = 123 }; + var preCheckoutQuery = new PreCheckoutQuery + { + Id = "test_query_id", + From = user, + InvoicePayload = "donation_500", + TotalAmount = 50000, // 500.00 RUB + Currency = "RUB", + }; + var update = new Update { PreCheckoutQuery = preCheckoutQuery }; + var cancellationToken = CancellationToken.None; + + var activeGoal = new DonationGoal { Id = 1, Title = "Test Goal", TargetAmount = 100000 }; + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(activeGoal); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert - verify that logs contain correct details + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => + v.ToString() !.Contains("Approved pre-checkout query") && + v.ToString() !.Contains("123") && // user ID + v.ToString() !.Contains("donation_500") && // payload + v.ToString() !.Contains("500") && // amount + v.ToString() !.Contains("RUB")), // currency + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that rejection logs contain correct details. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HandleAsyncWithoutActiveGoalLogsRejectionDetails() + { + // Arrange + var user = new User { Id = 123 }; + var preCheckoutQuery = new PreCheckoutQuery + { + Id = "test_query_id", + From = user, + InvoicePayload = "donation_500", + TotalAmount = 50000, + Currency = "RUB", + }; + var update = new Update { PreCheckoutQuery = preCheckoutQuery }; + var cancellationToken = CancellationToken.None; + + this.goalServiceMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(null as DonationGoal); + + // Act + await this.handler.HandleAsync(this.botClientMock.Object, update, cancellationToken); + + // Assert - verify that logs contain correct rejection details + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => + v.ToString() !.Contains("Rejected pre-checkout query") && + v.ToString() !.Contains("123") && // user ID + v.ToString() !.Contains("no active goal found")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} \ No newline at end of file diff --git a/TestBot/Bot/Bot.Service/AdminStateServiceTests.cs b/TestBot/Bot/Bot.Service/AdminStateServiceTests.cs new file mode 100755 index 0000000..032b9f1 --- /dev/null +++ b/TestBot/Bot/Bot.Service/AdminStateServiceTests.cs @@ -0,0 +1,525 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Services; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using static Bot.Services.AdminStateService; + +namespace Bot.Tests.Services; + +/// +/// Unit tests for the class. +/// +[TestFixture] +public class AdminStateServiceTests +{ + private Mock> loggerMock; + private AdminStateService adminStateService; + + /// + /// Sets up the test environment before each test execution. + /// + [SetUp] + public void Setup() + { + this.loggerMock = new Mock>(); + this.adminStateService = new AdminStateService(this.loggerMock.Object); + } + + /// + /// Tests that starting goal creation for a new user sets the initial state correctly. + /// + [Test] + public void StartGoalCreationNewUserSetsInitialState() + { + // Arrange + var userId = 123L; + var chatId = 456L; + + // Act + this.adminStateService.StartGoalCreation(userId, chatId); + + // Assert + var state = this.adminStateService.GetState(userId); + Assert.That(state, Is.Not.Null); + Assert.That(state.ChatId, Is.EqualTo(chatId)); + Assert.That(state.CurrentStep, Is.EqualTo(AdminGoalStep.WaitingForTitle)); + Assert.That(state.Title, Is.Null); + Assert.That(state.Description, Is.Null); + Assert.That(state.TargetAmount, Is.Null); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Started goal creation for admin user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that starting goal creation for an existing user overwrites the previous state. + /// + [Test] + public void StartGoalCreationExistingUserOverwritesState() + { + // Arrange + var userId = 123L; + var chatId = 456L; + this.adminStateService.StartGoalCreation(userId, chatId); + + // Act - start goal creation again + this.adminStateService.StartGoalCreation(userId, 789L); // New chatId + + // Assert + var state = this.adminStateService.GetState(userId); + Assert.That(state.ChatId, Is.EqualTo(789L)); + Assert.That(state.CurrentStep, Is.EqualTo(AdminGoalStep.WaitingForTitle)); + } + + /// + /// Tests that getting state for a non-existent user returns null. + /// + [Test] + public void GetStateNonExistentUserReturnsNull() + { + // Arrange + var userId = 999L; // Non-existent user + + // Act + var state = this.adminStateService.GetState(userId); + + // Assert + Assert.That(state, Is.Null); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("No state found for user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that getting state for an existing user returns the correct state. + /// + [Test] + public void GetStateExistingUserReturnsState() + { + // Arrange + var userId = 123L; + var chatId = 456L; + this.adminStateService.StartGoalCreation(userId, chatId); + + // Act + var state = this.adminStateService.GetState(userId); + + // Assert + Assert.That(state, Is.Not.Null); + Assert.That(state.ChatId, Is.EqualTo(chatId)); + } + + /// + /// Tests that setting title for an existing user updates the title and advances the step. + /// + [Test] + public void SetTitleExistingUserSetsTitleAndAdvancesStep() + { + // Arrange + var userId = 123L; + var chatId = 456L; + var title = "Новая цель"; + this.adminStateService.StartGoalCreation(userId, chatId); + + // Act + this.adminStateService.SetTitle(userId, title); + + // Assert + var state = this.adminStateService.GetState(userId); + Assert.That(state.Title, Is.EqualTo(title)); + Assert.That(state.CurrentStep, Is.EqualTo(AdminGoalStep.WaitingForDescription)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Set title for user") && v.ToString() !.Contains(title)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that setting title for a non-existent user logs a warning. + /// + [Test] + public void SetTitleNonExistentUserLogsWarning() + { + // Arrange + var userId = 999L; // Non-existent user + var title = "Новая цель"; + + // Act + this.adminStateService.SetTitle(userId, title); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Attempted to set title for non-existent user state")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that setting description for an existing user updates the description and advances the step. + /// + [Test] + public void SetDescriptionExistingUserSetsDescriptionAndAdvancesStep() + { + // Arrange + var userId = 123L; + var chatId = 456L; + var description = "Описание цели"; + this.adminStateService.StartGoalCreation(userId, chatId); + this.adminStateService.SetTitle(userId, "Название"); + + // Act + this.adminStateService.SetDescription(userId, description); + + // Assert + var state = this.adminStateService.GetState(userId); + Assert.That(state.Description, Is.EqualTo(description)); + Assert.That(state.CurrentStep, Is.EqualTo(AdminGoalStep.WaitingForAmount)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Set description for user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that setting description for a non-existent user logs a warning. + /// + [Test] + public void SetDescriptionNonExistentUserLogsWarning() + { + // Arrange + var userId = 999L; // Non-existent user + var description = "Описание цели"; + + // Act + this.adminStateService.SetDescription(userId, description); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Attempted to set description for non-existent user state")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that setting amount for an existing user updates the amount and completes the process. + /// + [Test] + public void SetAmountExistingUserSetsAmountAndCompletesProcess() + { + // Arrange + var userId = 123L; + var chatId = 456L; + var amount = 5000m; + this.adminStateService.StartGoalCreation(userId, chatId); + this.adminStateService.SetTitle(userId, "Название"); + this.adminStateService.SetDescription(userId, "Описание"); + + // Act + this.adminStateService.SetAmount(userId, amount); + + // Assert + var state = this.adminStateService.GetState(userId); + Assert.That(state.TargetAmount, Is.EqualTo(amount)); + Assert.That(state.CurrentStep, Is.EqualTo(AdminGoalStep.None)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Set amount for user") && v.ToString() !.Contains(amount.ToString())), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that setting amount for a non-existent user logs a warning. + /// + [Test] + public void SetAmountNonExistentUserLogsWarning() + { + // Arrange + var userId = 999L; // Non-existent user + var amount = 5000m; + + // Act + this.adminStateService.SetAmount(userId, amount); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Attempted to set amount for non-existent user state")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that canceling goal creation for an existing user removes the state. + /// + [Test] + public void CancelGoalCreationExistingUserRemovesState() + { + // Arrange + var userId = 123L; + var chatId = 456L; + this.adminStateService.StartGoalCreation(userId, chatId); + + // Act + this.adminStateService.CancelGoalCreation(userId); + + // Assert + var state = this.adminStateService.GetState(userId); + Assert.That(state, Is.Null); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Canceled goal creation for user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that canceling goal creation for a non-existent user logs a debug message. + /// + [Test] + public void CancelGoalCreationNonExistentUserLogsDebug() + { + // Arrange + var userId = 999L; // Non-existent user + + // Act + this.adminStateService.CancelGoalCreation(userId); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Attempted to cancel non-existent goal creation for user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a user in the goal creation process returns true. + /// + [Test] + public void IsUserCreatingGoalUserInProcessReturnsTrue() + { + // Arrange + var userId = 123L; + var chatId = 456L; + this.adminStateService.StartGoalCreation(userId, chatId); + + // Act + var isCreating = this.adminStateService.IsUserCreatingGoal(userId); + + // Assert + Assert.That(isCreating, Is.True); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("User") && v.ToString() !.Contains("goal creation status: True")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a user not in the goal creation process returns false. + /// + [Test] + public void IsUserCreatingGoalUserNotInProcessReturnsFalse() + { + // Arrange + var userId = 123L; // User has not started goal creation + + // Act + var isCreating = this.adminStateService.IsUserCreatingGoal(userId); + + // Assert + Assert.That(isCreating, Is.False); + } + + /// + /// Tests that a user who completed the goal creation process returns false. + /// + [Test] + public void IsUserCreatingGoalUserCompletedProcessReturnsFalse() + { + // Arrange + var userId = 123L; + var chatId = 456L; + this.adminStateService.StartGoalCreation(userId, chatId); + this.adminStateService.SetTitle(userId, "Название"); + this.adminStateService.SetDescription(userId, "Описание"); + this.adminStateService.SetAmount(userId, 5000m); // Complete the process + + // Act + var isCreating = this.adminStateService.IsUserCreatingGoal(userId); + + // Assert + Assert.That(isCreating, Is.False); + } + + /// + /// Tests that a user who cancelled the goal creation process returns false. + /// + [Test] + public void IsUserCreatingGoalUserCancelledProcessReturnsFalse() + { + // Arrange + var userId = 123L; + var chatId = 456L; + this.adminStateService.StartGoalCreation(userId, chatId); + this.adminStateService.CancelGoalCreation(userId); // Cancel the process + + // Act + var isCreating = this.adminStateService.IsUserCreatingGoal(userId); + + // Assert + Assert.That(isCreating, Is.False); + } + + /// + /// Tests that the active state count returns zero when there are no users. + /// + [Test] + public void GetActiveStateCountNoUsersReturnsZero() + { + // Arrange - no active users + + // Act + var count = this.adminStateService.GetActiveStateCount(); + + // Assert + Assert.That(count, Is.EqualTo(0)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Trace, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Current active admin states: 0")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the active state count returns the correct count for multiple users. + /// + [Test] + public void GetActiveStateCountMultipleUsersReturnsCorrectCount() + { + // Arrange + this.adminStateService.StartGoalCreation(123L, 456L); + this.adminStateService.StartGoalCreation(124L, 457L); + this.adminStateService.StartGoalCreation(125L, 458L); + + // Act + var count = this.adminStateService.GetActiveStateCount(); + + // Assert + Assert.That(count, Is.EqualTo(3)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Trace, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Current active admin states: 3")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the active state count is updated after cancellation. + /// + [Test] + public void GetActiveStateCountAfterCancellationReturnsUpdatedCount() + { + // Arrange + this.adminStateService.StartGoalCreation(123L, 456L); + this.adminStateService.StartGoalCreation(124L, 457L); + this.adminStateService.StartGoalCreation(125L, 458L); + + // Act - cancel one user + this.adminStateService.CancelGoalCreation(124L); + var count = this.adminStateService.GetActiveStateCount(); + + // Assert + Assert.That(count, Is.EqualTo(2)); + } + + /// + /// Tests that multiple users have independent states. + /// + [Test] + public void MultipleUsersIndependentStates() + { + // Arrange + var user1 = 123L; + var user2 = 124L; + var chat1 = 456L; + var chat2 = 457L; + + // Act + this.adminStateService.StartGoalCreation(user1, chat1); + this.adminStateService.StartGoalCreation(user2, chat2); + + this.adminStateService.SetTitle(user1, "Цель пользователя 1"); + this.adminStateService.SetTitle(user2, "Цель пользователя 2"); + + // Assert + var state1 = this.adminStateService.GetState(user1); + var state2 = this.adminStateService.GetState(user2); + + Assert.That(state1.Title, Is.EqualTo("Цель пользователя 1")); + Assert.That(state2.Title, Is.EqualTo("Цель пользователя 2")); + Assert.That(state1.ChatId, Is.EqualTo(chat1)); + Assert.That(state2.ChatId, Is.EqualTo(chat2)); + } +} \ No newline at end of file diff --git a/TestBot/Bot/Bot.Service/UserStateServiceTests.cs b/TestBot/Bot/Bot.Service/UserStateServiceTests.cs new file mode 100755 index 0000000..bbefb18 --- /dev/null +++ b/TestBot/Bot/Bot.Service/UserStateServiceTests.cs @@ -0,0 +1,509 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Services; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; + +namespace Bot.Tests.Services; + +/// +/// Unit tests for the class. +/// +[TestFixture] +public class UserStateServiceTests +{ + private Mock> loggerMock; + private UserStateService userStateService; + + /// + /// Sets up the test environment before each test execution. + /// + [SetUp] + public void Setup() + { + this.loggerMock = new Mock>(); + this.userStateService = new UserStateService(this.loggerMock.Object); + } + + /// + /// Tests that setting waiting for amount for a new user sets the state correctly. + /// + [Test] + public void SetWaitingForAmountNewUserSetsState() + { + // Arrange + var userId = 123L; + var chatId = 456L; + + // Act + this.userStateService.SetWaitingForAmount(userId, chatId); + + // Assert + var isWaiting = this.userStateService.IsWaitingForAmount(userId, chatId); + Assert.That(isWaiting, Is.True); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("User") && v.ToString() !.Contains("set to waiting for amount input")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that setting waiting for amount for an existing user updates the state. + /// + [Test] + public void SetWaitingForAmountExistingUserUpdatesState() + { + // Arrange + var userId = 123L; + this.userStateService.SetWaitingForAmount(userId, 456L); + + // Act - update chatId for the same user + this.userStateService.SetWaitingForAmount(userId, 789L); + + // Assert + var isWaitingOldChat = this.userStateService.IsWaitingForAmount(userId, 456L); + var isWaitingNewChat = this.userStateService.IsWaitingForAmount(userId, 789L); + + Assert.That(isWaitingOldChat, Is.False); + Assert.That(isWaitingNewChat, Is.True); + } + + /// + /// Tests that checking waiting for amount for a user not waiting returns false. + /// + [Test] + public void IsWaitingForAmountUserNotWaitingReturnsFalse() + { + // Arrange + var userId = 123L; + var chatId = 456L; + + // Act + var isWaiting = this.userStateService.IsWaitingForAmount(userId, chatId); + + // Assert + Assert.That(isWaiting, Is.False); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Checked waiting for amount status") && v.ToString() !.Contains("False")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that checking waiting for amount for a user waiting in a different chat returns false. + /// + [Test] + public void IsWaitingForAmountUserWaitingInDifferentChatReturnsFalse() + { + // Arrange + var userId = 123L; + this.userStateService.SetWaitingForAmount(userId, 456L); + + // Act + var isWaiting = this.userStateService.IsWaitingForAmount(userId, 789L); // Different chatId + + // Assert + Assert.That(isWaiting, Is.False); + } + + /// + /// Tests that checking waiting for amount for a user waiting in the same chat returns true. + /// + [Test] + public void IsWaitingForAmountUserWaitingInSameChatReturnsTrue() + { + // Arrange + var userId = 123L; + var chatId = 456L; + this.userStateService.SetWaitingForAmount(userId, chatId); + + // Act + var isWaiting = this.userStateService.IsWaitingForAmount(userId, chatId); + + // Assert + Assert.That(isWaiting, Is.True); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Checked waiting for amount status") && v.ToString() !.Contains("True")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that removing waiting for amount for an existing user removes the state. + /// + [Test] + public void RemoveWaitingForAmountExistingUserRemovesState() + { + // Arrange + var userId = 123L; + var chatId = 456L; + this.userStateService.SetWaitingForAmount(userId, chatId); + + // Act + this.userStateService.RemoveWaitingForAmount(userId); + + // Assert + var isWaiting = this.userStateService.IsWaitingForAmount(userId, chatId); + Assert.That(isWaiting, Is.False); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Removed user") && v.ToString() !.Contains("from waiting for amount state")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that removing waiting for amount for a non-existent user logs a debug message. + /// + [Test] + public void RemoveWaitingForAmountNonExistentUserLogsDebug() + { + // Arrange + var userId = 999L; // Non-existent user + + // Act + this.userStateService.RemoveWaitingForAmount(userId); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Attempted to remove non-existent waiting state for user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that getting waiting users count returns zero when there are no users. + /// + [Test] + public void GetWaitingUsersCountNoUsersReturnsZero() + { + // Arrange - no users in waiting state + + // Act + var count = this.userStateService.GetWaitingUsersCount(); + + // Assert + Assert.That(count, Is.EqualTo(0)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Trace, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Current users waiting for amount input: 0")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that getting waiting users count returns the correct count for multiple users. + /// + [Test] + public void GetWaitingUsersCountMultipleUsersReturnsCorrectCount() + { + // Arrange + this.userStateService.SetWaitingForAmount(123L, 456L); + this.userStateService.SetWaitingForAmount(124L, 457L); + this.userStateService.SetWaitingForAmount(125L, 458L); + + // Act + var count = this.userStateService.GetWaitingUsersCount(); + + // Assert + Assert.That(count, Is.EqualTo(3)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Trace, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Current users waiting for amount input: 3")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that getting waiting users count is updated after removal. + /// + [Test] + public void GetWaitingUsersCountAfterRemovalReturnsUpdatedCount() + { + // Arrange + this.userStateService.SetWaitingForAmount(123L, 456L); + this.userStateService.SetWaitingForAmount(124L, 457L); + this.userStateService.SetWaitingForAmount(125L, 458L); + + // Act - remove one user + this.userStateService.RemoveWaitingForAmount(124L); + var count = this.userStateService.GetWaitingUsersCount(); + + // Assert + Assert.That(count, Is.EqualTo(2)); + } + + /// + /// Tests that clearing all waiting states with users clears all states. + /// + [Test] + public void ClearAllWaitingStatesWithUsersClearsAllStates() + { + // Arrange + this.userStateService.SetWaitingForAmount(123L, 456L); + this.userStateService.SetWaitingForAmount(124L, 457L); + this.userStateService.SetWaitingForAmount(125L, 458L); + + // Act + this.userStateService.ClearAllWaitingStates(); + + // Assert + var count = this.userStateService.GetWaitingUsersCount(); + Assert.That(count, Is.EqualTo(0)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Cleared all waiting states, affected 3 users")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that clearing all waiting states with no users logs zero. + /// + [Test] + public void ClearAllWaitingStatesNoUsersLogsZero() + { + // Arrange - no users + + // Act + this.userStateService.ClearAllWaitingStates(); + + // Assert + this.loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Cleared all waiting states, affected 0 users")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that removing multiple waiting states for all existing users removes all. + /// + [Test] + public void RemoveMultipleWaitingStatesAllUsersExistRemovesAll() + { + // Arrange + this.userStateService.SetWaitingForAmount(123L, 456L); + this.userStateService.SetWaitingForAmount(124L, 457L); + this.userStateService.SetWaitingForAmount(125L, 458L); + + var userIdsToRemove = new long[] { 123L, 124L, 125L }; + + // Act + var removedCount = this.userStateService.RemoveMultipleWaitingStates(userIdsToRemove); + + // Assert + Assert.That(removedCount, Is.EqualTo(3)); + + var remainingCount = this.userStateService.GetWaitingUsersCount(); + Assert.That(remainingCount, Is.EqualTo(0)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Removed waiting states for 3 out of 3 users")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that removing multiple waiting states for some existing users removes only existing ones. + /// + [Test] + public void RemoveMultipleWaitingStatesSomeUsersExistRemovesOnlyExisting() + { + // Arrange + this.userStateService.SetWaitingForAmount(123L, 456L); + this.userStateService.SetWaitingForAmount(124L, 457L); + + var userIdsToRemove = new long[] { 123L, 124L, 125L }; + + // Act + var removedCount = this.userStateService.RemoveMultipleWaitingStates(userIdsToRemove); + + // Assert + Assert.That(removedCount, Is.EqualTo(2)); + + var remainingCount = this.userStateService.GetWaitingUsersCount(); + Assert.That(remainingCount, Is.EqualTo(0)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Removed waiting states for 2 out of 3 users")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that removing multiple waiting states for non-existent users returns zero. + /// + [Test] + public void RemoveMultipleWaitingStatesNoUsersExistReturnsZero() + { + // Arrange + var userIdsToRemove = new long[] { 123L, 124L, 125L }; + + // Act + var removedCount = this.userStateService.RemoveMultipleWaitingStates(userIdsToRemove); + + // Assert + Assert.That(removedCount, Is.EqualTo(0)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Removed waiting states for 0 out of 3 users")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that removing multiple waiting states with an empty list returns zero. + /// + [Test] + public void RemoveMultipleWaitingStatesEmptyListReturnsZero() + { + // Arrange + var userIdsToRemove = Array.Empty(); + + // Act + var removedCount = this.userStateService.RemoveMultipleWaitingStates(userIdsToRemove); + + // Assert + Assert.That(removedCount, Is.EqualTo(0)); + + this.loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Removed waiting states for 0 out of 0 users")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests a complex scenario with multiple operations works correctly. + /// + [Test] + public void MultipleOperationsComplexScenarioWorksCorrectly() + { + // Arrange & Act - complex scenario + this.userStateService.SetWaitingForAmount(123L, 456L); + this.userStateService.SetWaitingForAmount(124L, 457L); + + var countAfterAdd = this.userStateService.GetWaitingUsersCount(); + Assert.That(countAfterAdd, Is.EqualTo(2)); + + // Check states + var user123Waiting = this.userStateService.IsWaitingForAmount(123L, 456L); + var user124Waiting = this.userStateService.IsWaitingForAmount(124L, 457L); + Assert.That(user123Waiting, Is.True); + Assert.That(user124Waiting, Is.True); + + // Update state for one user + this.userStateService.SetWaitingForAmount(123L, 789L); + + var user123OldChat = this.userStateService.IsWaitingForAmount(123L, 456L); + var user123NewChat = this.userStateService.IsWaitingForAmount(123L, 789L); + Assert.That(user123OldChat, Is.False); + Assert.That(user123NewChat, Is.True); + + // Remove one user + this.userStateService.RemoveWaitingForAmount(124L); + + var countAfterRemove = this.userStateService.GetWaitingUsersCount(); + Assert.That(countAfterRemove, Is.EqualTo(1)); + + // Clear all + this.userStateService.ClearAllWaitingStates(); + + var finalCount = this.userStateService.GetWaitingUsersCount(); + Assert.That(finalCount, Is.EqualTo(0)); + } + + /// + /// Tests that independent users do not interfere with each other's states. + /// + [Test] + public void IndependentUsersDoNotInterfere() + { + // Arrange + var user1 = 123L; + var user2 = 124L; + var chat1 = 456L; + var chat2 = 457L; + + // Act + this.userStateService.SetWaitingForAmount(user1, chat1); + this.userStateService.SetWaitingForAmount(user2, chat2); + + // Assert - check that states are independent + var user1InChat1 = this.userStateService.IsWaitingForAmount(user1, chat1); + var user1InChat2 = this.userStateService.IsWaitingForAmount(user1, chat2); + var user2InChat1 = this.userStateService.IsWaitingForAmount(user2, chat1); + var user2InChat2 = this.userStateService.IsWaitingForAmount(user2, chat2); + + Assert.That(user1InChat1, Is.True); + Assert.That(user1InChat2, Is.False); + Assert.That(user2InChat1, Is.False); + Assert.That(user2InChat2, Is.True); + + // Remove only user1 + this.userStateService.RemoveWaitingForAmount(user1); + + var user1AfterRemove = this.userStateService.IsWaitingForAmount(user1, chat1); + var user2AfterRemove = this.userStateService.IsWaitingForAmount(user2, chat2); + + Assert.That(user1AfterRemove, Is.False); + Assert.That(user2AfterRemove, Is.True); + } +} \ No newline at end of file diff --git a/TestBot/Bot/UpdateHandlerTests.cs b/TestBot/Bot/UpdateHandlerTests.cs new file mode 100755 index 0000000..32c4055 --- /dev/null +++ b/TestBot/Bot/UpdateHandlerTests.cs @@ -0,0 +1,255 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using Bot.Handlers; +using Microsoft.Extensions.Logging; +using Moq; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Payments; + +namespace Bot.Tests +{ + /// + /// Contains unit tests for the class. + /// Tests various scenarios including update routing, exception handling, + /// logging behavior, and handler selection logic. + /// + [TestFixture] + public class UpdateHandlerTests + { + private Mock> loggerMock; + private Mock botClientMock; + private List> handlerMocks; + private UpdateHandler updateHandler; + + /// + /// Initializes test setup before each test execution. + /// Creates mock instances for logger, bot client, and prepares empty handler list. + /// + [SetUp] + public void Setup() + { + loggerMock = new Mock>(); + botClientMock = new Mock(); + + // Create mock handlers + handlerMocks = new List>(); + + var handlers = new List(); + updateHandler = new UpdateHandler(loggerMock.Object, handlers); + } + + /// + /// Tests that a warning log is recorded when no handlers are available to process an update. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task HandleUpdateAsyncNoHandlersLogsWarning() + { + // Arrange + var update = new Update { Id = 123 }; + var cancellationToken = CancellationToken.None; + + // Act + await updateHandler.HandleUpdateAsync(botClientMock.Object, update, cancellationToken); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("No handler found for update")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that only the first matching handler is executed when multiple handlers could process an update. + /// Verifies that subsequent matching handlers are not checked or executed. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task HandleUpdateAsyncFirstMatchingHandlerUsedDoesNotCheckOtherHandlers() + { + // Arrange + var update = new Update { Id = 123, CallbackQuery = new CallbackQuery() }; + var cancellationToken = CancellationToken.None; + + var firstHandlerMock = new Mock(); + firstHandlerMock.Setup(h => h.CanHandle(update)).Returns(true); + firstHandlerMock.Setup(h => h.HandleAsync(botClientMock.Object, update, cancellationToken)) + .Returns(Task.CompletedTask); + + var secondHandlerMock = new Mock(); + + // This one also matches but should not be called + secondHandlerMock.Setup(h => h.CanHandle(update)).Returns(true); + + AddHandler(firstHandlerMock); + AddHandler(secondHandlerMock); + + // Act + await updateHandler.HandleUpdateAsync(botClientMock.Object, update, cancellationToken); + + // Assert + firstHandlerMock.Verify(h => h.CanHandle(update), Times.Once); + firstHandlerMock.Verify(h => h.HandleAsync(botClientMock.Object, update, cancellationToken), Times.Once); + + // Should not be checked + secondHandlerMock.Verify(h => h.CanHandle(update), Times.Never); + } + + /// + /// Tests that an error log is recorded when a handler throws an exception during update processing. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task HandleUpdateAsyncHandlerThrowsExceptionLogsError() + { + // Arrange + var update = new Update { Id = 123, Message = new Message() }; + var cancellationToken = CancellationToken.None; + var exception = new Exception("Handler failed"); + + var handlerMock = new Mock(); + handlerMock.Setup(h => h.CanHandle(update)).Returns(true); + handlerMock.Setup(h => h.HandleAsync(botClientMock.Object, update, cancellationToken)) + .ThrowsAsync(exception); + + AddHandler(handlerMock); + + // Act + await updateHandler.HandleUpdateAsync(botClientMock.Object, update, cancellationToken); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error processing update")), + exception, + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a debug log is recorded when an update is received. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task HandleUpdateAsyncLogsDebugOnReceipt() + { + // Arrange + var update = new Update { Id = 123 }; + var cancellationToken = CancellationToken.None; + + // Act + await updateHandler.HandleUpdateAsync(botClientMock.Object, update, cancellationToken); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Received update") && v.ToString() !.Contains("123")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the constructor logs the count of registered handlers during initialization. + /// + [Test] + public void ConstructorWithHandlersLogsHandlerCount() + { + // Arrange + var handlers = new List + { + new Mock().Object, + new Mock().Object, + new Mock().Object, + }; + + // Act + var handler = new UpdateHandler(loggerMock.Object, handlers); + + // Assert + loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("UpdateHandler initialized with 3 handlers")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that different types of updates are correctly routed to their corresponding handlers. + /// Verifies that message updates go to message handlers, callback updates to callback handlers, etc. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task HandleUpdateAsyncMultipleHandlerTypesRoutesCorrectly() + { + // Arrange + var messageUpdate = new Update { Id = 1, Message = new Message() }; + var callbackUpdate = new Update { Id = 2, CallbackQuery = new CallbackQuery() }; + var preCheckoutUpdate = new Update { Id = 3, PreCheckoutQuery = new PreCheckoutQuery() }; + var cancellationToken = CancellationToken.None; + + var messageHandlerMock = new Mock(); + messageHandlerMock.Setup(h => h.CanHandle(It.Is(u => u.Message != null))).Returns(true); + + var callbackHandlerMock = new Mock(); + callbackHandlerMock.Setup(h => h.CanHandle(It.Is(u => u.CallbackQuery != null))).Returns(true); + + var preCheckoutHandlerMock = new Mock(); + preCheckoutHandlerMock.Setup(h => h.CanHandle(It.Is(u => u.PreCheckoutQuery != null))).Returns(true); + + AddHandler(messageHandlerMock); + AddHandler(callbackHandlerMock); + AddHandler(preCheckoutHandlerMock); + + // Act & Assert - Message update + await updateHandler.HandleUpdateAsync(botClientMock.Object, messageUpdate, cancellationToken); + messageHandlerMock.Verify( + h => h.HandleAsync(botClientMock.Object, messageUpdate, cancellationToken), + Times.Once); + callbackHandlerMock.Verify( + h => h.HandleAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + preCheckoutHandlerMock.Verify( + h => h.HandleAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + + // Act & Assert - Callback update + await updateHandler.HandleUpdateAsync(botClientMock.Object, callbackUpdate, cancellationToken); + callbackHandlerMock.Verify( + h => h.HandleAsync(botClientMock.Object, callbackUpdate, cancellationToken), + Times.Once); + + // Act & Assert - PreCheckout update + await updateHandler.HandleUpdateAsync(botClientMock.Object, preCheckoutUpdate, cancellationToken); + preCheckoutHandlerMock.Verify( + h => h.HandleAsync(botClientMock.Object, preCheckoutUpdate, cancellationToken), + Times.Once); + } + + /// + /// Helper method to add a mock handler to the update handler instance. + /// Recreates the update handler with the updated handler collection. + /// + /// The mock handler to add. + private void AddHandler(Mock handlerMock) + { + handlerMocks.Add(handlerMock); + var handlers = handlerMocks.Select(m => m.Object).ToList(); + updateHandler = new UpdateHandler(loggerMock.Object, handlers); + } + } +} \ No newline at end of file diff --git a/TestBot/Data/DonationServiceTests.cs b/TestBot/Data/DonationServiceTests.cs new file mode 100755 index 0000000..57102c2 --- /dev/null +++ b/TestBot/Data/DonationServiceTests.cs @@ -0,0 +1,534 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using System; +using System.Threading.Tasks; +using Data; +using Data.Models; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Services; + +namespace Services.Tests; + +/// +/// Contains unit tests for the class. +/// Tests various scenarios including user management, donation processing, +/// exception handling, and logging behavior. +/// +[TestFixture] +public class DonationServiceTests +{ + private Mock repositoryMock; + private Mock> loggerMock; + private DonationService donationService; + + /// + /// Initializes test environment before each test execution. + /// Creates mock instances for repository and logger, and instantiates the donation service. + /// + [SetUp] + public void Setup() + { + repositoryMock = new Mock(); + loggerMock = new Mock>(); + donationService = new DonationService(repositoryMock.Object, loggerMock.Object); + } + + /// + /// Tests that when a user already exists in the database, + /// the method returns the existing user without creating a new one. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetOrCreateUserAsyncExistingUserReturnsUser() + { + // Arrange + var telegramId = 123L; + var existingUser = new Users { Id = 1, TelegramId = telegramId, Username = "testuser", FirstName = "Test", LastName = "User" }; + + repositoryMock + .Setup(x => x.GetUserByTelegramIdAsync(telegramId)) + .ReturnsAsync(existingUser); + + // Act + var result = await donationService.GetOrCreateUserAsync(telegramId, "testuser", "Test", "User"); + + // Assert + Assert.That(result, Is.EqualTo(existingUser)); + repositoryMock.Verify(x => x.GetUserByTelegramIdAsync(telegramId), Times.Once); + repositoryMock.Verify(x => x.CreateUserAsync(It.IsAny()), Times.Never); + + loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Found existing user")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when a user does not exist in the database, + /// the method creates a new user with the provided information. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetOrCreateUserAsyncNewUserCreatesUser() + { + // Arrange + var telegramId = 123L; + var newUser = new Users + { + Id = 1, + TelegramId = telegramId, + Username = "newuser", + FirstName = "New", + LastName = "User", + Admin = false, + }; + + repositoryMock + .Setup(x => x.GetUserByTelegramIdAsync(telegramId)) + .ReturnsAsync((Users)null!); + + repositoryMock + .Setup(x => x.CreateUserAsync(It.Is(u => + u.TelegramId == telegramId && + u.Username == "newuser" && + u.FirstName == "New" && + u.LastName == "User" && + u.Admin == false))) + .ReturnsAsync(newUser); + + // Act + var result = await donationService.GetOrCreateUserAsync(telegramId, "newuser", "New", "User"); + + // Assert + Assert.That(result, Is.EqualTo(newUser)); + repositoryMock.Verify(x => x.GetUserByTelegramIdAsync(telegramId), Times.Once); + repositoryMock.Verify(x => x.CreateUserAsync(It.IsAny()), Times.Once); + + loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Creating new user") && v.ToString() !.Contains("newuser")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the method correctly handles null values for user properties + /// when creating a new user. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetOrCreateUserAsyncNullNamesHandlesCorrectly() + { + // Arrange + var telegramId = 123L; + var newUser = new Users { Id = 1, TelegramId = telegramId, Username = null, FirstName = null, LastName = null }; + + repositoryMock + .Setup(x => x.GetUserByTelegramIdAsync(telegramId)) + .ReturnsAsync((Users)null!); + + repositoryMock + .Setup(x => x.CreateUserAsync(It.Is(u => + u.TelegramId == telegramId && + u.Username == null && + u.FirstName == null && + u.LastName == null))) + .ReturnsAsync(newUser); + + // Act + var result = await donationService.GetOrCreateUserAsync(telegramId, null, null, null); + + // Assert + Assert.That(result, Is.EqualTo(newUser)); + } + + /// + /// Tests that when the repository throws an exception, + /// the method logs the error and rethrows the exception. + /// + [Test] + public void GetOrCreateUserAsyncRepositoryThrowsLogsErrorAndThrows() + { + // Arrange + var telegramId = 123L; + var exception = new Exception("Database error"); + + repositoryMock + .Setup(x => x.GetUserByTelegramIdAsync(telegramId)) + .ThrowsAsync(exception); + + // Act & Assert + Assert.ThrowsAsync(() => + donationService.GetOrCreateUserAsync(telegramId, "testuser", "Test", "User")); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error getting or creating user")), + exception, + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a successful donation is processed correctly, + /// creating a donation record and updating the goal amount. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task ProcessDonationAsyncSuccessfulDonationReturnsTrue() + { + // Arrange + var userId = 123L; + var amount = 500m; + var currency = "RUB"; + var donationId = "donation_123"; + var goal = new DonationGoal { Id = 1, Title = "Test Goal", TargetAmount = 10000, CurrentAmount = 0 }; + var donation = new Donation { Id = 1, UserTelegramId = userId, GoalId = goal.Id, Amount = amount, Currency = currency, ProviderPaymentId = donationId }; + + repositoryMock + .Setup(x => x.GetDonationAsync(donationId)) + .ReturnsAsync((Donation)null!); + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + repositoryMock + .Setup(x => x.CreateDonationAsync(It.Is(d => + d.UserTelegramId == userId && + d.GoalId == goal.Id && + d.Amount == amount && + d.Currency == currency && + d.ProviderPaymentId == donationId && + d.Status == "completed"))) + .ReturnsAsync(donation); + + repositoryMock + .Setup(x => x.UpdateGoalCurrentAmountAsync(goal.Id, amount)) + .Returns(Task.CompletedTask); + + // Act + var result = await donationService.ProcessDonationAsync(userId, amount, currency, donationId); + + // Assert + Assert.That(result, Is.True); + + repositoryMock.Verify(x => x.GetDonationAsync(donationId), Times.Once); + repositoryMock.Verify(x => x.GetActiveGoalAsync(), Times.Once); + repositoryMock.Verify(x => x.CreateDonationAsync(It.IsAny()), Times.Once); + repositoryMock.Verify(x => x.UpdateGoalCurrentAmountAsync(goal.Id, amount), Times.Once); + + loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Successfully processed donation")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when a duplicate donation is detected (same donation ID), + /// the method returns true without creating a new donation record. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task ProcessDonationAsyncDuplicateDonationReturnsTrue() + { + // Arrange + var userId = 123L; + var amount = 500m; + var goalId = 1; + var currency = "RUB"; + var donationId = "donation_123"; + var existingDonation = new Donation { Id = 1, UserTelegramId = userId, GoalId = goalId, Amount = amount, CreatedAt = DateTime.UtcNow }; + + repositoryMock + .Setup(x => x.GetDonationAsync(donationId)) + .ReturnsAsync(existingDonation); + + // Act + var result = await donationService.ProcessDonationAsync(userId, amount, currency, donationId); + + // Assert + Assert.That(result, Is.True); + + repositoryMock.Verify(x => x.GetDonationAsync(donationId), Times.Once); + repositoryMock.Verify(x => x.GetActiveGoalAsync(), Times.Never); + repositoryMock.Verify(x => x.CreateDonationAsync(It.IsAny()), Times.Never); + repositoryMock.Verify(x => x.UpdateGoalCurrentAmountAsync(It.IsAny(), It.IsAny()), Times.Never); + + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("has already been processed")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when there is no active donation goal, + /// the method returns false and logs an error. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task ProcessDonationAsyncNoActiveGoalReturnsFalse() + { + // Arrange + var userId = 123L; + var amount = 500m; + var currency = "RUB"; + var donationId = "donation_123"; + + repositoryMock + .Setup(x => x.GetDonationAsync(donationId)) + .ReturnsAsync((Donation)null!); + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync((DonationGoal)null!); + + // Act + var result = await donationService.ProcessDonationAsync(userId, amount, currency, donationId); + + // Assert + Assert.That(result, Is.False); + + repositoryMock.Verify(x => x.GetDonationAsync(donationId), Times.Once); + repositoryMock.Verify(x => x.GetActiveGoalAsync(), Times.Once); + repositoryMock.Verify(x => x.CreateDonationAsync(It.IsAny()), Times.Never); + repositoryMock.Verify(x => x.UpdateGoalCurrentAmountAsync(It.IsAny(), It.IsAny()), Times.Never); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("No active goal found for donation")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when creating a donation record throws an exception, + /// the method returns false and logs the error. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task ProcessDonationAsyncCreateDonationThrowsReturnsFalse() + { + // Arrange + var userId = 123L; + var amount = 500m; + var currency = "RUB"; + var donationId = "donation_123"; + var goal = new DonationGoal { Id = 1, Title = "Test Goal" }; + var exception = new Exception("Database error"); + + repositoryMock + .Setup(x => x.GetDonationAsync(donationId)) + .ReturnsAsync((Donation)null!); + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + repositoryMock + .Setup(x => x.CreateDonationAsync(It.IsAny())) + .ThrowsAsync(exception); + + // Act + var result = await donationService.ProcessDonationAsync(userId, amount, currency, donationId); + + // Assert + Assert.That(result, Is.False); + + repositoryMock.Verify(x => x.GetDonationAsync(donationId), Times.Once); + repositoryMock.Verify(x => x.GetActiveGoalAsync(), Times.Once); + repositoryMock.Verify(x => x.CreateDonationAsync(It.IsAny()), Times.Once); + repositoryMock.Verify(x => x.UpdateGoalCurrentAmountAsync(It.IsAny(), It.IsAny()), Times.Never); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error processing donation")), + exception, + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when updating the goal amount throws an exception, + /// the method returns false and logs the error. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task ProcessDonationAsyncUpdateGoalThrowsReturnsFalse() + { + // Arrange + var userId = 123L; + var amount = 500m; + var currency = "RUB"; + var donationId = "donation_123"; + var goal = new DonationGoal { Id = 1, Title = "Test Goal" }; + var donation = new Donation { Id = 1, UserTelegramId = userId, GoalId = goal.Id, Amount = amount }; + var exception = new Exception("Database error"); + + repositoryMock + .Setup(x => x.GetDonationAsync(donationId)) + .ReturnsAsync((Donation)null!); + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + repositoryMock + .Setup(x => x.CreateDonationAsync(It.IsAny())) + .ReturnsAsync(donation); + + repositoryMock + .Setup(x => x.UpdateGoalCurrentAmountAsync(goal.Id, amount)) + .ThrowsAsync(exception); + + // Act + var result = await donationService.ProcessDonationAsync(userId, amount, currency, donationId); + + // Assert + Assert.That(result, Is.False); + + repositoryMock.Verify(x => x.GetDonationAsync(donationId), Times.Once); + repositoryMock.Verify(x => x.GetActiveGoalAsync(), Times.Once); + repositoryMock.Verify(x => x.CreateDonationAsync(It.IsAny()), Times.Once); + repositoryMock.Verify(x => x.UpdateGoalCurrentAmountAsync(goal.Id, amount), Times.Once); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error processing donation")), + exception, + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the donation record is created with the correct status ("completed"). + /// + /// A task representing the asynchronous operation. + [Test] + public async Task ProcessDonationAsyncValidatesDonationStatus() + { + // Arrange + var userId = 123L; + var amount = 500m; + var currency = "RUB"; + var donationId = "donation_123"; + var goal = new DonationGoal { Id = 1, Title = "Test Goal" }; + var donation = new Donation { Id = 1, UserTelegramId = userId, GoalId = goal.Id, Amount = amount }; + + repositoryMock + .Setup(x => x.GetDonationAsync(donationId)) + .ReturnsAsync((Donation)null!); + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + repositoryMock + .Setup(x => x.CreateDonationAsync(It.Is(d => d.Status == "completed"))) + .ReturnsAsync(donation); + + repositoryMock + .Setup(x => x.UpdateGoalCurrentAmountAsync(goal.Id, amount)) + .Returns(Task.CompletedTask); + + // Act + var result = await donationService.ProcessDonationAsync(userId, amount, currency, donationId); + + // Assert + Assert.That(result, Is.True); + + // Проверяем, что статус доната установлен в "completed" + repositoryMock.Verify(x => x.CreateDonationAsync(It.Is(d => d.Status == "completed")), Times.Once); + } + + /// + /// Tests that appropriate log messages are recorded during donation processing. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task ProcessDonationAsyncLogsAppropriateMessages() + { + // Arrange + var userId = 123L; + var amount = 500m; + var currency = "RUB"; + var donationId = "donation_123"; + var goal = new DonationGoal { Id = 1, Title = "Test Goal" }; + var donation = new Donation { Id = 1, UserTelegramId = userId, GoalId = goal.Id, Amount = amount }; + + repositoryMock + .Setup(x => x.GetDonationAsync(donationId)) + .ReturnsAsync((Donation)null!); + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + repositoryMock + .Setup(x => x.CreateDonationAsync(It.IsAny())) + .ReturnsAsync(donation); + + repositoryMock + .Setup(x => x.UpdateGoalCurrentAmountAsync(goal.Id, amount)) + .Returns(Task.CompletedTask); + + // Act + await donationService.ProcessDonationAsync(userId, amount, currency, donationId); + + // Assert - проверяем последовательность логирования + loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Processing donation")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Found active goal")), + It.IsAny(), + It.IsAny>()), + Times.Once); + + loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Created donation record")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} \ No newline at end of file diff --git a/TestBot/Data/GoalServiceTests.cs b/TestBot/Data/GoalServiceTests.cs new file mode 100755 index 0000000..16b1a58 --- /dev/null +++ b/TestBot/Data/GoalServiceTests.cs @@ -0,0 +1,659 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using System; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Data; +using Data.Models; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Services; + +namespace Services.Tests; + +/// +/// Contains unit tests for the class. +/// Tests user authorization, goal management, statistics generation, and progress tracking functionality. +/// +[TestFixture] +public class GoalServiceTests +{ + private Mock repositoryMock; + private Mock> loggerMock; + private GoalService goalService; + + /// + /// Initializes test environment before each test execution. + /// Sets up mock dependencies, creates service instance, and configures Russian culture for formatting tests. + /// + [SetUp] + public void Setup() + { + repositoryMock = new Mock(); + loggerMock = new Mock>(); + goalService = new GoalService(repositoryMock.Object, loggerMock.Object); + + Thread.CurrentThread.CurrentCulture = new CultureInfo("ru-RU"); + Thread.CurrentThread.CurrentUICulture = new CultureInfo("ru-RU"); + } + + /// + /// Tests that a user with admin privileges is correctly identified as an administrator. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task IsUserAdminAsyncAdminUserReturnsTrue() + { + // Arrange + var telegramId = 123L; + var adminUser = new Users { Id = 1, TelegramId = telegramId, Admin = true }; + + repositoryMock + .Setup(x => x.GetUserByTelegramIdAsync(telegramId)) + .ReturnsAsync(adminUser); + + // Act + var result = await goalService.IsUserAdminAsync(telegramId); + + // Assert + Assert.That(result, Is.True); + + loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("User") && v.ToString() !.Contains("admin status: True")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a non-admin user is correctly identified as not having administrator privileges. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task IsUserAdminAsyncNonAdminUserReturnsFalse() + { + // Arrange + var telegramId = 123L; + var regularUser = new Users { Id = 1, TelegramId = telegramId, Admin = false }; + + repositoryMock + .Setup(x => x.GetUserByTelegramIdAsync(telegramId)) + .ReturnsAsync(regularUser); + + // Act + var result = await goalService.IsUserAdminAsync(telegramId); + + // Assert + Assert.That(result, Is.False); + } + + /// + /// Tests that when a user is not found in the database, the method returns false. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task IsUserAdminAsyncUserNotFoundReturnsFalse() + { + // Arrange + var telegramId = 999L; + + repositoryMock + .Setup(x => x.GetUserByTelegramIdAsync(telegramId)) + .ReturnsAsync((Users)null!); + + // Act + var result = await goalService.IsUserAdminAsync(telegramId); + + // Assert + Assert.That(result, Is.False); + } + + /// + /// Tests that when the repository throws an exception, the method returns false and logs an error. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task IsUserAdminAsyncRepositoryThrowsReturnsFalseAndLogsError() + { + // Arrange + var telegramId = 123L; + var exception = new Exception("Database error"); + + repositoryMock + .Setup(x => x.GetUserByTelegramIdAsync(telegramId)) + .ThrowsAsync(exception); + + // Act + var result = await goalService.IsUserAdminAsync(telegramId); + + // Assert + Assert.That(result, Is.False); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error checking admin status")), + exception, + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when an active goal exists, it is successfully retrieved from the repository. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetActiveGoalAsyncGoalExistsReturnsGoal() + { + // Arrange + var goal = new DonationGoal { Id = 1, Title = "Test Goal", TargetAmount = 10000, CurrentAmount = 5000 }; + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + // Act + var result = await goalService.GetActiveGoalAsync(); + + // Assert + Assert.That(result, Is.EqualTo(goal)); + + loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Active goal retrieval succeeded")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when no active goal exists, the method returns null. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetActiveGoalAsyncNoGoalReturnsNull() + { + // Arrange + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync((DonationGoal)null!); + + // Act + var result = await goalService.GetActiveGoalAsync(); + + // Assert + Assert.That(result, Is.Null); + + loggerMock.Verify( + x => x.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Active goal retrieval failed - no active goal")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when the repository throws an exception, the method returns null and logs an error. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetActiveGoalAsyncRepositoryThrowsReturnsNullAndLogsError() + { + // Arrange + var exception = new Exception("Database error"); + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ThrowsAsync(exception); + + // Act + var result = await goalService.GetActiveGoalAsync(); + + // Assert + Assert.That(result, Is.Null); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error retrieving active goal")), + exception, + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that when an active goal exists, detailed statistics are correctly formatted and returned. + /// Includes verification of progress bars, percentage calculations, and formatted currency values. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetGoalStatsAsyncWithActiveGoalReturnsFormattedStats() + { + // Arrange + var goal = new DonationGoal + { + Id = 1, + Title = "Test Goal", + Description = "Test Description", + TargetAmount = 10000, + CurrentAmount = 5000, + CreatedAt = new DateTime(2024, 1, 1), + }; + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + repositoryMock + .Setup(x => x.GetCountUsersForActiveGoals()) + .ReturnsAsync(25); + + repositoryMock + .Setup(x => x.GetCountDonationsForActiveGoals()) + .ReturnsAsync(50); + + // Act + var result = await goalService.GetGoalStatsAsync(); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Contains.Substring("Test Goal")); + Assert.That(result, Contains.Substring("Test Description")); + Assert.That(result, Contains.Substring("10\u00A0000₽")); + Assert.That(result, Contains.Substring("5\u00A0000₽")); + Assert.That(result, Contains.Substring("50,0%")); + Assert.That(result, Contains.Substring("25")); + Assert.That(result, Contains.Substring("50")); + Assert.That(result, Contains.Substring("01.01.2024")); + Assert.That(result, Contains.Substring("[■■■■■□□□□□]")); // 50% progress bar + + repositoryMock.Verify(x => x.GetActiveGoalAsync(), Times.Once); + repositoryMock.Verify(x => x.GetCountUsersForActiveGoals(), Times.Once); + repositoryMock.Verify(x => x.GetCountDonationsForActiveGoals(), Times.Once); + } + + /// + /// Tests that when no active goal exists, an appropriate message is returned to the user. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetGoalStatsAsyncNoActiveGoalReturnsNoActiveGoalMessage() + { + // Arrange + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync((DonationGoal)null!); + + // Act + var result = await goalService.GetGoalStatsAsync(); + + // Assert + Assert.That(result, Is.EqualTo("🎯 На данный момент нет активных целей для сбора.")); + + loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("No active goal found for statistics")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that goal statistics handle zero target amounts correctly to prevent division by zero. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetGoalStatsAsyncZeroTargetAmountHandlesCorrectly() + { + // Arrange + var goal = new DonationGoal + { + Id = 1, + Title = "Test Goal", + TargetAmount = 0, + CurrentAmount = 5000, + }; + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + repositoryMock + .Setup(x => x.GetCountUsersForActiveGoals()) + .ReturnsAsync(10); + + repositoryMock + .Setup(x => x.GetCountDonationsForActiveGoals()) + .ReturnsAsync(20); + + // Act + var result = await goalService.GetGoalStatsAsync(); + + // Assert + Assert.That(result, Is.Not.Null); + var today = DateTime.UtcNow.ToString("dd.MM.yyyy"); + Assert.That(result, Contains.Substring( + $"🎯 **Test Goal** — 0₽ \n" + + $"📝 Описание: \n\n" + + $"📈 Количество пожертвований на текущую цель: 20\n" + + $"🧮 Количество пожертвовавших: 10 \n" + + $"⏳ Дата открытия сбора: {today}\n\n" + + $"Собрано: 5\u00A0000₽ (0,0%) \n" + + $"[□□□□□□□□□□]")); + } + + /// + /// Tests that when goal progress reaches 100%, a full progress bar is displayed. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetGoalStatsAsyncFullProgressShowsFullProgressBar() + { + // Arrange + var goal = new DonationGoal + { + Id = 1, + Title = "Test Goal", + TargetAmount = 1000, + CurrentAmount = 1000, + }; + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + repositoryMock + .Setup(x => x.GetCountUsersForActiveGoals()) + .ReturnsAsync(10); + + repositoryMock + .Setup(x => x.GetCountDonationsForActiveGoals()) + .ReturnsAsync(20); + + // Act + var result = await goalService.GetGoalStatsAsync(); + + // Assert + Assert.That(result, Contains.Substring("[■■■■■■■■■■]")); // 100% progress bar + Assert.That(result, Contains.Substring("100,0%")); + } + + /// + /// Tests that when repository operations fail, an error message is returned and the exception is logged. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetGoalStatsAsyncRepositoryThrowsReturnsErrorMessage() + { + // Arrange + var exception = new Exception("Database error"); + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ThrowsAsync(exception); + + // Act + var result = await goalService.GetGoalStatsAsync(); + + // Assert + Assert.That(result, Is.EqualTo("🎯 На данный момент нет активных целей для сбора.")); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error retrieving active goal")), + exception, + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that start command statistics are correctly formatted when an active goal exists. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetStartStatsWithActiveGoalReturnsFormattedStats() + { + // Arrange + var goal = new DonationGoal + { + Id = 1, + Title = "Test Goal", + Description = "Test Description", + TargetAmount = 10000, + CurrentAmount = 7500, + }; + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + // Act + var result = await goalService.GetStartStats(); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Contains.Substring("Test Goal")); + Assert.That(result, Contains.Substring("Test Description")); + Assert.That(result, Contains.Substring("10\u00A0000₽")); + Assert.That(result, Contains.Substring("7\u00A0500₽")); + Assert.That(result, Contains.Substring("75,0%")); + Assert.That(result, Contains.Substring("[■■■■■■■■□□]")); // 75% progress bar (rounded to 8 blocks out of 10) + } + + /// + /// Tests that when no active goal exists for start command, an appropriate message is returned. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetStartStatsNoActiveGoalReturnsNoActiveGoalMessage() + { + // Arrange + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync((DonationGoal)null!); + + // Act + var result = await goalService.GetStartStats(); + + // Assert + Assert.That(result, Is.EqualTo("🎯 На данный момент нет активных целей для сбора.")); + + loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("No active goal found for start statistics")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that repository exceptions during start statistics retrieval are handled gracefully. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetStartStatsRepositoryThrowsReturnsErrorMessage() + { + // Arrange + var exception = new Exception("Database error"); + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ThrowsAsync(exception); + + // Act + var result = await goalService.GetStartStats(); + + // Assert + Assert.That(result, Is.EqualTo("🎯 На данный момент нет активных целей для сбора.")); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error retrieving active goal")), + exception, + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that a valid goal is successfully created and persisted to the repository. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task CreateGoalAsyncValidGoalCreatesAndReturnsGoal() + { + // Arrange + var title = "New Goal"; + var description = "New Description"; + var targetAmount = 5000m; + var createdGoal = new DonationGoal { Id = 1, Title = title, Description = description, TargetAmount = targetAmount }; + + repositoryMock + .Setup(x => x.CreateGoalAsync(It.Is(g => + g.Title == title && + g.Description == description && + g.TargetAmount == targetAmount && + g.IsActive == true))) + .ReturnsAsync(createdGoal); + + // Act + var result = await goalService.CreateGoalAsync(title, description, targetAmount); + + // Assert + Assert.That(result, Is.EqualTo(createdGoal)); + + loggerMock.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Goal created successfully") && v.ToString() !.Contains(title)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that repository exceptions during goal creation are properly logged and re-thrown. + /// + [Test] + public void CreateGoalAsyncRepositoryThrowsLogsErrorAndThrows() + { + // Arrange + var title = "New Goal"; + var description = "New Description"; + var targetAmount = 5000m; + var exception = new Exception("Database error"); + + repositoryMock + .Setup(x => x.CreateGoalAsync(It.IsAny())) + .ThrowsAsync(exception); + + // Act & Assert + Assert.ThrowsAsync(() => + goalService.CreateGoalAsync(title, description, targetAmount)); + + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString() !.Contains("Error creating goal") && v.ToString() !.Contains(title)), + exception, + It.IsAny>()), + Times.Once); + } + + /// + /// Tests that the progress bar generation correctly converts percentage values to visual representations. + /// Includes edge cases like 0%, 100%, and values exceeding 100%. + /// + [Test] + public void CreateProgressBarVariousPercentagesReturnsCorrectBars() + { + // Arrange + var service = new GoalService(repositoryMock.Object, loggerMock.Object); + + // Use reflection to test private method + var method = typeof(GoalService).GetMethod( + "CreateProgressBar", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + // Act & Assert + var result0 = method?.Invoke(service, new object[] { 0.0 }) as string; + var result25 = method?.Invoke(service, new object[] { 25.0 }) as string; + var result50 = method?.Invoke(service, new object[] { 50.0 }) as string; + var result75 = method?.Invoke(service, new object[] { 75.0 }) as string; + var result99 = method?.Invoke(service, new object[] { 99.0 }) as string; + var result100 = method?.Invoke(service, new object[] { 100.0 }) as string; + var result110 = method?.Invoke(service, new object[] { 110.0 }) as string; + + Assert.That(result0, Is.EqualTo("[□□□□□□□□□□]")); + Assert.That(result25, Is.EqualTo("[■■□□□□□□□□]")); + Assert.That(result50, Is.EqualTo("[■■■■■□□□□□]")); + Assert.That(result75, Is.EqualTo("[■■■■■■■■□□]")); + Assert.That(result99, Is.EqualTo("[■■■■■■■■■■]")); // 99% rounds up to full bar + Assert.That(result100, Is.EqualTo("[■■■■■■■■■■]")); + Assert.That(result110, Is.EqualTo("[■■■■■■■■■■]")); // Over 100% still shows full bar + } + + /// + /// Tests that partial progress percentages are correctly rounded for visual progress bar display. + /// + /// A task representing the asynchronous operation. + [Test] + public async Task GetGoalStatsAsyncPartialProgressBarRoundsCorrectly() + { + // Arrange + var goal = new DonationGoal + { + Id = 1, + Title = "Test Goal", + TargetAmount = 1000, + CurrentAmount = 123, // 12.3% + }; + + repositoryMock + .Setup(x => x.GetActiveGoalAsync()) + .ReturnsAsync(goal); + + repositoryMock + .Setup(x => x.GetCountUsersForActiveGoals()) + .ReturnsAsync(5); + + repositoryMock + .Setup(x => x.GetCountDonationsForActiveGoals()) + .ReturnsAsync(10); + + // Act + var result = await goalService.GetGoalStatsAsync(); + + var today = DateTime.UtcNow.ToString("dd.MM.yyyy"); + Assert.That(result, Contains.Substring( + $"🎯 **Test Goal** — 1\u00A0000₽ \n" + + $"📝 Описание: \n\n" + + $"📈 Количество пожертвований на текущую цель: 10\n" + + $"🧮 Количество пожертвовавших: 5 \n" + + $"⏳ Дата открытия сбора: {today}\n\n" + + $"Собрано: 123₽ (12,3%) \n" + + $"[■□□□□□□□□□]")); + } +} \ No newline at end of file diff --git a/TestBot/TestBot.csproj b/TestBot/TestBot.csproj new file mode 100755 index 0000000..f25913a --- /dev/null +++ b/TestBot/TestBot.csproj @@ -0,0 +1,35 @@ + + + + net9.0 + latest + enable + enable + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..a2723f6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +#version: '3.8' + +services: + postgres: + image: postgres:17 + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./Data/Scripts/create_tables.sql:/docker-entrypoint-initdb.d/01-create-tables.sql + # настрой /var/lib/postgresql/data/pg_hba.conf под себя + ports: + - "5432:5432" + networks: + - donationbot-network + + bot: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + environment: + - ASPNETCORE_ENVIRONMENT=Production + - BotConfig__BotToken=${BotConfig__BotToken} + - BotConfig__PaymentProviderToken=${BotConfig__PaymentProviderToken} + - DatabaseConfig__ConnectionString=${DatabaseConfig__ConnectionString} + depends_on: + - postgres + networks: + - donationbot-network + +networks: + donationbot-network: + +volumes: + postgres_data: