diff --git a/.clang-tidy b/.clang-tidy index 230fa6f..1e7cc46 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,12 +1,4 @@ -Checks: > - clang-analyzer-*, - bugprone-*, - performance-*, - modernize-*, - readability-*, - -modernize-use-trailing-return-type - +Checks: 'clang-analyzer-*,bugprone-*,performance-*,modernize-*,readability-*,-modernize-use-trailing-return-type,-bugprone-suspicious-include' WarningsAsErrors: '' - -HeaderFilterRegex: '^(src|include|tests|benchmark)/' +HeaderFilterRegex: '.*/moderation-service/(src|tests|benchmark|include)/.*' FormatStyle: file \ No newline at end of file diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index e69de29..daccead 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -0,0 +1,22 @@ +name: "lint and test" + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + types: [ opened, synchronize, reopened ] + +permissions: + contents: read + pull-requests: write + +jobs: + lint-and-test: + uses: esclient/tools/.github/workflows/lint-and-test-cpp.yml@main + with: + sonar-inclusions: "src/**,tests/**" + sonar-exclusions: "**/generated/**,**/grpc/**,tests/mocks/**,tests/unit/**" + sonar-coverage-exclusions: "tests/mocks/**,tests/unit/**,src/moderationservice/main.cpp,src/moderationservice/server/server.cpp" + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index ac1f9e0..4b4eec5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,6 @@ cmake_minimum_required(VERSION 3.20) project(moderation-service) +enable_testing() set(CMAKE_CXX_STANDARD 17) if(MSVC) @@ -18,14 +19,21 @@ if(WIN32) add_compile_definitions(U_STATIC_IMPLEMENTATION) endif() +option(ENABLE_COVERAGE "Enable coverage reporting" OFF) +if(ENABLE_COVERAGE) + add_compile_options(--coverage -fprofile-arcs -ftest-coverage) + add_link_options(--coverage) +endif() + include(${CMAKE_BINARY_DIR}/conan_toolchain.cmake OPTIONAL) -find_package(Protobuf REQUIRED CONFIG) +find_package(protobuf REQUIRED CONFIG) find_package(gRPC REQUIRED CONFIG) find_package(ICU REQUIRED COMPONENTS uc i18n data) find_package(RdKafka REQUIRED) find_package(Threads REQUIRED) find_package(libpqxx REQUIRED) +find_package(absl CONFIG REQUIRED) set(CPM_DOWNLOAD_VERSION 0.40.2) if(CPM_SOURCE_CACHE) @@ -82,11 +90,9 @@ add_custom_command( # Create library from generated sources add_library(${GENPROTO_LIB} ${PROTO_SRCS} ${PROTO_HDRS} ${GRPC_SRCS} ${GRPC_HDRS}) -target_include_directories( - ${GENPROTO_LIB} PUBLIC ${GENPROTO_DIR} ${protobuf_INCLUDE_DIRS_RELEASE} - ${grpc_INCLUDE_DIRS_RELEASE} ${icu_INCLUDE_DIRS_RELEASE}) +target_include_directories(${GENPROTO_LIB} PUBLIC ${GENPROTO_DIR}) -target_link_libraries(${GENPROTO_LIB} PUBLIC protobuf::libprotobuf gRPC::grpc++) +target_link_libraries(${GENPROTO_LIB} PUBLIC protobuf::libprotobuf gRPC::grpc++ abseil::abseil) # link source files add_executable( @@ -117,7 +123,8 @@ target_link_libraries( Threads::Threads RdKafka::rdkafka RdKafka::rdkafka++ - libpqxx::pqxx) + libpqxx::pqxx + abseil::abseil) if(WIN32) target_compile_definitions(${PROJECT_NAME} PRIVATE U_STATIC_IMPLEMENTATION) @@ -256,9 +263,9 @@ if(CPPCHECK_EXE COMMAND ${CMAKE_COMMAND} -E echo "=== Running cppcheck ===" COMMAND ${CPPCHECK_EXE} --enable=warning,style,performance,portability --inline-suppr - --suppress=*:${CMAKE_BINARY_DIR}/generated/* -I${PROJECT_SOURCE_DIR}/include - -I${PROJECT_SOURCE_DIR}/src/moderationservice --library=gnu - ${PROJECT_SOURCE_DIR}/src/moderationservice ${PROJECT_SOURCE_DIR}/include + --suppress=*:${CMAKE_BINARY_DIR}/generated/* --suppress=*:${CMAKE_BINARY_DIR}/_deps/* + -I${PROJECT_SOURCE_DIR}/include -I${PROJECT_SOURCE_DIR}/src/moderationservice + --library=gnu ${PROJECT_SOURCE_DIR}/src/moderationservice ${PROJECT_SOURCE_DIR}/include ${PROJECT_SOURCE_DIR}/tests ${PROJECT_SOURCE_DIR}/benchmark COMMAND ${CMAKE_COMMAND} -E echo "" COMMAND ${CMAKE_COMMAND} -E echo "=== Running cpplint ===" diff --git a/README.md b/README.md index c96f674..be61625 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ Unbenannt-2025-10-16-0020(1) - To build and run you need the following installed: -Chocolatey (Windows) @@ -17,3 +16,4 @@ To build and run you need the following installed: -sh/bash (Windows only) -Internet connection (Optional) -Electricity (Recommended) +-Antidepressants(Recommended) diff --git a/include/config/config.hpp b/include/config/config.hpp index 0ba090d..f5157d2 100644 --- a/include/config/config.hpp +++ b/include/config/config.hpp @@ -18,44 +18,38 @@ struct Config { const char* host = std::getenv("HOST"); if (host == nullptr) { throw std::runtime_error("HOST environment variable not set"); - } else { - config.host = host; } + config.host = host; const char* port = std::getenv("PORT"); if (port == nullptr) { throw std::runtime_error("PORT environment variable not set"); - } else { - config.port = port; } + config.port = port; const char* database_url = std::getenv("DATABASE_URL"); if (database_url == nullptr) { throw std::runtime_error("DATABASE_URL environment variable not set"); - } else { - config.database_url = database_url; } + config.database_url = database_url; const char* log_level = std::getenv("LOG_LEVEL"); if (log_level == nullptr) { throw std::runtime_error("LOG_LEVEL environment variable not set"); - } else { - config.log_level = log_level; } + config.log_level = log_level; const char* log_format = std::getenv("LOG_FORMAT"); if (log_format == nullptr) { throw std::runtime_error("LOG_FORMAT environment variable not set"); - } else { - config.log_format = log_format; } + config.log_format = log_format; const char* log_datefmt = std::getenv("LOG_DATEFMT"); if (log_datefmt == nullptr) { throw std::runtime_error("LOG_DATEFMT environment variable not set"); - } else { - config.log_datefmt = log_datefmt; } + config.log_datefmt = log_datefmt; return config; } @@ -76,49 +70,45 @@ struct KafkaConfig { const char* kafka_brokers = std::getenv("KAFKA_BROKERS"); if (kafka_brokers == nullptr) { throw std::runtime_error("KAFKA_BROKERS environment variable not set"); - } else { - kafkaConfig.brokers = kafka_brokers; } + kafkaConfig.brokers = kafka_brokers; + const char* kafka_request_topic = std::getenv("KAFKA_REQUEST_TOPIC"); if (kafka_request_topic == nullptr) { throw std::runtime_error("KAFKA_REQUEST_TOPIC environment variable not set"); - } else { - kafkaConfig.request_topic = kafka_request_topic; } + kafkaConfig.request_topic = kafka_request_topic; + const char* kafka_result_topic = std::getenv("KAFKA_RESULT_TOPIC"); if (kafka_result_topic == nullptr) { throw std::runtime_error("KAFKA_RESULT_TOPIC environment variable not set"); - } else { - kafkaConfig.result_topic = kafka_result_topic; } + kafkaConfig.result_topic = kafka_result_topic; + const char* kafka_consumer_group_id = std::getenv("KAFKA_CONSUMER_GROUP_ID"); if (kafka_consumer_group_id == nullptr) { throw std::runtime_error("KAFKA_CONSUMER_GROUP_ID environment variable not set"); - } else { - kafkaConfig.consumer_group_id = kafka_consumer_group_id; } + kafkaConfig.consumer_group_id = kafka_consumer_group_id; + const char* kafka_max_retries = std::getenv("KAFKA_MAX_RETRIES"); if (kafka_max_retries == nullptr) { throw std::runtime_error("KAFKA_MAX_RETRIES environment variable not set"); - } else { - kafkaConfig.max_retries = std::stoi(kafka_max_retries); } + kafkaConfig.max_retries = std::stoi(kafka_max_retries); + const char* kafka_retry_backoff_ms = std::getenv("KAFKA_RETRY_BACKOFF_MS"); if (kafka_retry_backoff_ms == nullptr) { throw std::runtime_error("KAFKA_RETRY_BACKOFF_MS environment variable not set"); - } else { - kafkaConfig.retry_backoff_ms = std::stoi(kafka_retry_backoff_ms); } + kafkaConfig.retry_backoff_ms = std::stoi(kafka_retry_backoff_ms); + const char* kafka_enable_ssl = std::getenv("KAFKA_ENABLE_SSL"); if (kafka_enable_ssl == nullptr) { throw std::runtime_error("KAFKA_ENABLE_SSL environment variable not set"); - } else { - if (std::string(kafka_enable_ssl) == "true" || std::string(kafka_enable_ssl) == "1") { - kafkaConfig.enable_ssl = true; - } else { - kafkaConfig.enable_ssl = false; - } } + const std::string enableSslStr(kafka_enable_ssl); + kafkaConfig.enable_ssl = (enableSslStr == "true" || enableSslStr == "1"); return kafkaConfig; } diff --git a/include/config/timeouts.hpp b/include/config/timeouts.hpp index a6aae3f..b4101aa 100644 --- a/include/config/timeouts.hpp +++ b/include/config/timeouts.hpp @@ -2,12 +2,10 @@ #include -namespace moderation { -namespace config { +namespace moderation::config { // Kafka timeouts in milliseconds constexpr int32_t KAFKA_FLUSH_TIMEOUT_MS = 10000; constexpr int32_t KAFKA_CONSUME_TIMEOUT_MS = 1000; -} // namespace config -} // namespace moderation +} // namespace moderation::config diff --git a/src/moderationservice/kafka/kafkaclient.cpp b/src/moderationservice/kafka/kafkaclient.cpp index c1d7ea1..e15e8e5 100644 --- a/src/moderationservice/kafka/kafkaclient.cpp +++ b/src/moderationservice/kafka/kafkaclient.cpp @@ -9,7 +9,7 @@ #include #include -KafkaClient::KafkaClient(const KafkaConfig& config) : config_(config), initialized_(false) {} +KafkaClient::KafkaClient(KafkaConfig config) : config_(std::move(config)) {} void KafkaClient::Initialize(std::function result_callback) { diff --git a/src/moderationservice/kafka/kafkaclient.hpp b/src/moderationservice/kafka/kafkaclient.hpp index 98c6f1d..1f477f5 100644 --- a/src/moderationservice/kafka/kafkaclient.hpp +++ b/src/moderationservice/kafka/kafkaclient.hpp @@ -1,6 +1,7 @@ #pragma once #include "config/config.hpp" +#include "config/timeouts.hpp" #include "moderation.pb.h" #include #include @@ -10,6 +11,7 @@ #include #include #include +#include #include class KafkaProducer; @@ -27,8 +29,8 @@ class ConsumerEventCb : public RdKafka::EventCb { class KafkaClient { public: - explicit KafkaClient(const KafkaConfig& config); - ~KafkaClient(); + explicit KafkaClient(KafkaConfig config); + virtual ~KafkaClient(); virtual void Initialize(std::function @@ -44,7 +46,7 @@ class KafkaClient { KafkaConfig config_; std::unique_ptr producer_; std::unique_ptr consumer_; - bool initialized_; + bool initialized_{false}; std::mutex mutex_; }; @@ -57,8 +59,8 @@ class KafkaProducer { const std::string& topic); bool SendResponseAsync(const moderation::ModerateObjectResponse& response, const std::string& topic, const std::string& key); - bool Flush(int timeoutMs = 10000); - bool isHealthy() const { return producer_ != nullptr; } + bool Flush(int timeoutMs = moderation::config::KAFKA_FLUSH_TIMEOUT_MS); + [[nodiscard]] bool isHealthy() const { return producer_ != nullptr; } private: KafkaConfig config_; @@ -79,7 +81,7 @@ class KafkaConsumer { void Start(); void Stop(); - bool isRunning() const { return running_.load(); } + [[nodiscard]] bool isRunning() const { return running_.load(); } private: void ConsumeLoop(); diff --git a/src/moderationservice/model/constants.hpp b/src/moderationservice/model/constants.hpp index ac994be..2ce8407 100644 --- a/src/moderationservice/model/constants.hpp +++ b/src/moderationservice/model/constants.hpp @@ -21,4 +21,7 @@ extern tsl::htrie_set forbiddenWords; void InitializeForbiddenWords(); } // namespace HashTrieMaps +namespace Thresholds { +inline constexpr unsigned int FORBIDDEN_WORD_THRESHOLD = 3; +} // namespace Thresholds } // namespace TextProcessingConstants diff --git a/src/moderationservice/model/model_utils.hpp b/src/moderationservice/model/model_utils.hpp index c1c8b7e..ac4ecf6 100644 --- a/src/moderationservice/model/model_utils.hpp +++ b/src/moderationservice/model/model_utils.hpp @@ -3,10 +3,8 @@ #include "moderation.pb.h" #include -namespace moderation { -namespace utils { +namespace moderation::utils { std::string ObjectTypeToString(moderation::ObjectType type); -} // namespace utils -} // namespace moderation +} // namespace moderation::utils diff --git a/src/moderationservice/model/unicode_constants.hpp b/src/moderationservice/model/unicode_constants.hpp index d273bee..9e4b874 100644 --- a/src/moderationservice/model/unicode_constants.hpp +++ b/src/moderationservice/model/unicode_constants.hpp @@ -29,4 +29,16 @@ constexpr UChar32 PARAGRAPH_SEPARATOR = 0x2029; constexpr UChar32 BIDI_MARKERS_START = 0x202A; constexpr UChar32 BIDI_MARKERS_END = 0x202E; +// Cyrillic block +constexpr UChar32 CYRILLIC_BLOCK_START = 0x0400; +constexpr UChar32 CYRILLIC_BLOCK_END = 0x04FF; + +// Latin uppercase A–Z +constexpr UChar32 LATIN_UPPERCASE_START = 0x0041; +constexpr UChar32 LATIN_UPPERCASE_END = 0x005A; + +// Latin lowercase a–z +constexpr UChar32 LATIN_LOWERCASE_START = 0x0061; +constexpr UChar32 LATIN_LOWERCASE_END = 0x007A; + } // namespace unicode_constants diff --git a/src/moderationservice/service/leetspeak_normalization.cpp b/src/moderationservice/service/leetspeak_normalization.cpp index d5bbb5a..915b945 100644 --- a/src/moderationservice/service/leetspeak_normalization.cpp +++ b/src/moderationservice/service/leetspeak_normalization.cpp @@ -1,5 +1,6 @@ #include "service/leetspeak_normalization.hpp" #include "model/constants.hpp" +#include "model/unicode_constants.hpp" #include namespace LeetspeakNormalization { @@ -10,28 +11,25 @@ const auto& homoglyphMap = TextProcessingConstants::LeetspeakMaps::homoglyphMap; std::string LeetspeakNormalization(const std::string& textN, bool isCyrillic) { icu::UnicodeString text = icu::UnicodeString::fromUTF8(textN); - if (text.length() == 0) { - text = icu::UnicodeString(textN.c_str(), textN.length(), "UTF-8"); - } icu::UnicodeString result; for (int32_t i = 0; i < text.length(); i++) { - UChar32 c = text.char32At(i); - UChar32 normalized = c; + UChar32 character = text.char32At(i); + UChar32 normalized = character; if (isCyrillic) { - auto homoglyphText = homoglyphMap.find(c); + auto homoglyphText = homoglyphMap.find(character); if (homoglyphText != homoglyphMap.end()) { normalized = homoglyphText->second; } else { - auto leetedTextCyrillic = cyrillicMap.find(c); + auto leetedTextCyrillic = cyrillicMap.find(character); if (leetedTextCyrillic != cyrillicMap.end()) { normalized = leetedTextCyrillic->second; } } } else { - auto leetedText = latinMap.find(c); + auto leetedText = latinMap.find(character); if (leetedText != latinMap.end()) { normalized = leetedText->second; } @@ -51,22 +49,20 @@ std::string LeetspeakNormalization(const std::string& textN, bool isCyrillic) { bool IsPrimarilyCyrillic(const std::string& textN) { icu::UnicodeString text = icu::UnicodeString::fromUTF8(textN); - if (text.length() == 0) { - text = icu::UnicodeString(textN.c_str(), textN.length(), "UTF-8"); - } - int cyrillicCount = 0; int latinCount = 0; for (int32_t i = 0; i < text.length(); i++) { - UChar32 c = text.char32At(i); - - // Cyrillic range: U+0400 to U+04FF - if (c >= 0x0400 && c <= 0x04FF) { + if (UChar32 character = text.char32At(i); + character >= unicode_constants::CYRILLIC_BLOCK_START && + character <= unicode_constants::CYRILLIC_BLOCK_END) { cyrillicCount++; - } else if ((c >= 0x0041 && c <= 0x005A) || (c >= 0x0061 && c <= 0x007A)) { + } else if ((character >= unicode_constants::LATIN_UPPERCASE_START && + character <= unicode_constants::LATIN_UPPERCASE_END) || + (character >= unicode_constants::LATIN_LOWERCASE_START && + character <= unicode_constants::LATIN_LOWERCASE_END)) { latinCount++; - } // Latin range + } if (U16_IS_LEAD(text.charAt(i))) { i++; diff --git a/src/moderationservice/service/text_normalization.cpp b/src/moderationservice/service/text_normalization.cpp index 2d19347..5a068ae 100644 --- a/src/moderationservice/service/text_normalization.cpp +++ b/src/moderationservice/service/text_normalization.cpp @@ -17,10 +17,6 @@ std::string TextNormalization(const std::string& textN) { return ""; } - if (text.length() == 0) { - text = icu::UnicodeString(textN.c_str(), static_cast(textN.length()), "UTF-8"); - } - icu::UnicodeString normalized = normalizer->normalize(text, status); if (U_FAILURE(status) != 0) { @@ -42,10 +38,6 @@ std::string WhitespaceNormalization(const std::string& textN) { icu::UnicodeString text = icu::UnicodeString::fromUTF8(textN); icu::UnicodeString result; - if (text.length() == 0) { - text = icu::UnicodeString(textN.c_str(), static_cast(textN.length()), "UTF-8"); - } - bool prev_space = false; for (int32_t i = 0; i < text.length(); i++) { @@ -75,10 +67,6 @@ std::string RepetitionNormalization(const std::string& textN) { UChar32 prevChar = 0; - if (text.length() == 0) { - text = icu::UnicodeString(textN.c_str(), static_cast(textN.length()), "UTF-8"); - } - for (int32_t i = 0; i < text.length(); i++) { UChar32 character = text.char32At(i); @@ -99,9 +87,7 @@ std::string RepetitionNormalization(const std::string& textN) { std::string InvisibleCharacterNormalization(const std::string& textN) { icu::UnicodeString text = icu::UnicodeString::fromUTF8(textN); - if (text.length() == 0) { - text = icu::UnicodeString(textN.c_str(), static_cast(textN.length()), "UTF-8"); - } + icu::UnicodeString result; for (int32_t i = 0; i < text.length(); i++) { diff --git a/src/moderationservice/service/word_checker.cpp b/src/moderationservice/service/word_checker.cpp index 0895c8f..a07662e 100644 --- a/src/moderationservice/service/word_checker.cpp +++ b/src/moderationservice/service/word_checker.cpp @@ -1,5 +1,6 @@ #include "service/word_checker.hpp" #include "model/constants.hpp" +#include #include #include @@ -10,16 +11,12 @@ const auto& forbiddenWords = TextProcessingConstants::HashTrieMaps::forbiddenWor std::vector Tokenize(const std::string& textN) { std::vector tokens; icu::UnicodeString text = icu::UnicodeString::fromUTF8(textN); - if (text.length() == 0) { - text = icu::UnicodeString(textN.c_str(), textN.length(), "UTF-8"); - } + std::string currentWord; for (int32_t i = 0; i < text.length(); i++) { - UChar32 c = text.char32At(i); - - if (u_isalnum(c)) { - icu::UnicodeString charStr(c); + if (UChar32 character = text.char32At(i); u_isalnum(character)) { + icu::UnicodeString charStr(character); std::string utf8char; charStr.toUTF8String(utf8char); currentWord += utf8char; @@ -42,15 +39,12 @@ std::vector Tokenize(const std::string& textN) { return tokens; } bool WordChecking(const std::vector& textN) { - unsigned int forbiddenWordCount = 0; - - for (int i = 0; i < textN.size(); i++) { - if (forbiddenWords.find(textN[i]) != forbiddenWords.end()) { - forbiddenWordCount++; - } - } + unsigned int forbiddenWordCount = + std::count_if(textN.begin(), textN.end(), [](const std::string& word) { + return forbiddenWords.find(word) != forbiddenWords.end(); + }); - return (forbiddenWordCount > 3); + return (forbiddenWordCount > TextProcessingConstants::Thresholds::FORBIDDEN_WORD_THRESHOLD); } } // namespace WordChecker diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0b2d0ff..1c58d75 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -46,7 +46,7 @@ function(add_service_test TEST_NAME) RdKafka::rdkafka++ ${TEST_DEPS} ) - add_test(NAME ${TEST_NAME} COMMAND ${TEST_NAME}) + gtest_discover_tests(${TEST_NAME} DISCOVERY_MODE PRE_TEST) endfunction() # ============================================================================ @@ -56,22 +56,18 @@ add_service_test(fake_repository_test ${CMAKE_CURRENT_SOURCE_DIR}/unit/repository/repository_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mocks/fake_moderation_repository.cpp ) -gtest_discover_tests(fake_repository_test) add_service_test(text_normalization_test ${CMAKE_CURRENT_SOURCE_DIR}/unit/service/text_normalization_test.cpp ) -gtest_discover_tests(text_normalization_test) add_service_test(word_checker_test ${CMAKE_CURRENT_SOURCE_DIR}/unit/service/word_checker_test.cpp ) -gtest_discover_tests(word_checker_test) add_service_test(text_processor_test ${CMAKE_CURRENT_SOURCE_DIR}/unit/service/text_processor_test.cpp ) -gtest_discover_tests(text_processor_test) add_service_test(moderation_service_test ${CMAKE_CURRENT_SOURCE_DIR}/unit/service/moderation_service_test.cpp @@ -81,7 +77,6 @@ add_service_test(moderation_service_test ${CMAKE_CURRENT_SOURCE_DIR}/mocks/test_utils.cpp ${CMAKE_SOURCE_DIR}/src/moderationservice/kafka/kafkaclient.cpp ) -gtest_discover_tests(moderation_service_test) add_service_test(handler_test ${CMAKE_CURRENT_SOURCE_DIR}/unit/handler/handler_test.cpp @@ -92,4 +87,7 @@ add_service_test(handler_test ${CMAKE_CURRENT_SOURCE_DIR}/mocks/mock_kafka_client.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mocks/test_utils.cpp ) -gtest_discover_tests(handler_test) + +add_service_test(leetspeak_normalization_test + ${CMAKE_CURRENT_SOURCE_DIR}/unit/service/leetspeak_normalization_test.cpp +) diff --git a/tests/mocks/fake_moderation_repository.hpp b/tests/mocks/fake_moderation_repository.hpp index 3d9782d..6acbaae 100644 --- a/tests/mocks/fake_moderation_repository.hpp +++ b/tests/mocks/fake_moderation_repository.hpp @@ -6,8 +6,8 @@ class FakeModerationRepository : public IModerationRepository { public: - bool SaveModerationResult(const ModerationRecord& result); - const std::vector& GetStoredRecords() const { return stored_; } + bool SaveModerationResult(const ModerationRecord& result) override; + [[nodiscard]] const std::vector& GetStoredRecords() const { return stored_; } void Clear() { stored_.clear(); } void SetSaveResult(bool value) { save_result_ = value; } diff --git a/tests/mocks/mock_kafka_client.hpp b/tests/mocks/mock_kafka_client.hpp index 854c61c..e588145 100644 --- a/tests/mocks/mock_kafka_client.hpp +++ b/tests/mocks/mock_kafka_client.hpp @@ -16,9 +16,10 @@ class MockKafkaClient : public KafkaClient { const std::string&, moderation::ObjectType)> callback) override; bool SendRequestAsync(const moderation::ModerateObjectRequest& request) override; + ~MockKafkaClient() override = default; void SetSendResult(bool value) { send_result_ = value; } - const std::vector& GetSentRequests() const { + [[nodiscard]] const std::vector& GetSentRequests() const { return sent_requests_; } void ClearSentRequests() { sent_requests_.clear(); } diff --git a/tests/mocks/test_utils.hpp b/tests/mocks/test_utils.hpp index a2c8f0d..a9075fe 100644 --- a/tests/mocks/test_utils.hpp +++ b/tests/mocks/test_utils.hpp @@ -12,7 +12,7 @@ ModerationRecord MakeRecord(int64_t object_id, const std::string& text, bool is_ moderation::ObjectType type = moderation::OBJECT_TYPE_UNSPECIFIED); moderation::ModerateObjectRequest -MakeRequest(int64_t id, const std::string& text, +MakeRequest(int64_t request_id, const std::string& text, moderation::ObjectType type = moderation::OBJECT_TYPE_UNSPECIFIED); moderation::ModerateObjectResponse MakeResponse(bool success); diff --git a/tests/unit/service/leetspeak_normalization_test.cpp b/tests/unit/service/leetspeak_normalization_test.cpp index 391a977..9eba000 100644 --- a/tests/unit/service/leetspeak_normalization_test.cpp +++ b/tests/unit/service/leetspeak_normalization_test.cpp @@ -14,3 +14,47 @@ TEST(LeetspeakNormalization, IsPrimarilyCyrillic_CyrillicWin) { TEST(LeetspeakNormalization, IsPrimarilyCyrillic_LatinWin) { EXPECT_FALSE(LeetspeakNormalization::IsPrimarilyCyrillic("hello hello привет")); } + +TEST(LeetspeakNormalization, EmptyInputReturnsEmpty) { + std::string outLatin = LeetspeakNormalization::LeetspeakNormalization("", false); + std::string outCyrillic = LeetspeakNormalization::LeetspeakNormalization("", true); + + EXPECT_EQ(outLatin, ""); + EXPECT_EQ(outCyrillic, ""); +} + +TEST(LeetspeakNormalization, CyrillicHomoglyphMapping) { + std::string out = LeetspeakNormalization::LeetspeakNormalization("a", true); + // Latin 'a' (U+0061) mapped to Cyrillic 'а' (U+0430) + EXPECT_EQ(out, "а"); +} + +TEST(LeetspeakNormalization, CyrillicLeetDigitMapping) { + std::string out = LeetspeakNormalization::LeetspeakNormalization("0", true); + // '0' mapped to Cyrillic 'о' + EXPECT_EQ(out, "о"); +} + +TEST(LeetspeakNormalization, CyrillicNoMappingKeepsCharacter) { + std::string out = LeetspeakNormalization::LeetspeakNormalization("?", true); + EXPECT_EQ(out, "?"); +} + +TEST(LeetspeakNormalization, SurrogatePairIsHandledCorrectly) { + std::string input = "a😊b"; + std::string out = LeetspeakNormalization::LeetspeakNormalization(input, false); + EXPECT_EQ(out, input); +} + +TEST(LeetspeakNormalization, IsPrimarilyCyrillic_EqualCountsReturnsFalse) { + EXPECT_FALSE(LeetspeakNormalization::IsPrimarilyCyrillic("aа")); +} + +TEST(LeetspeakNormalization, IsPrimarilyCyrillic_NoLettersReturnsFalse) { + EXPECT_FALSE(LeetspeakNormalization::IsPrimarilyCyrillic("1234!@#$")); +} + +TEST(LeetspeakNormalization, IsPrimarilyCyrillic_SurrogatePairIgnoredForCounts) { + std::string text = "привет😊hello"; + EXPECT_TRUE(LeetspeakNormalization::IsPrimarilyCyrillic(text)); +} diff --git a/tests/unit/service/text_processor_test.cpp b/tests/unit/service/text_processor_test.cpp index 9d23fd7..b3f0b39 100644 --- a/tests/unit/service/text_processor_test.cpp +++ b/tests/unit/service/text_processor_test.cpp @@ -14,7 +14,8 @@ TEST_F(TextProcessorTest, CleanTextReturnsFalse) { TEST_F(TextProcessorTest, ManyForbiddenWordsReturnsTrue) { std::string bad; - for (int i = 0; i < 5; ++i) { + constexpr int testForbiddenWordRepetitions = 5; + for (int i = 0; i < testForbiddenWordRepetitions; ++i) { bad += "gay "; } diff --git a/tests/unit/service/word_checker_test.cpp b/tests/unit/service/word_checker_test.cpp index 1fe8c9a..22037b8 100644 --- a/tests/unit/service/word_checker_test.cpp +++ b/tests/unit/service/word_checker_test.cpp @@ -11,7 +11,7 @@ class WordCheckerTest : public ::testing::Test { TEST_F(WordCheckerTest, TokenizeSimple) { auto tokens = WordChecker::Tokenize("hello world"); - ASSERT_EQ(2u, tokens.size()); + ASSERT_EQ(2U, tokens.size()); EXPECT_EQ(tokens[0], "hello"); EXPECT_EQ(tokens[1], "world"); } @@ -36,3 +36,31 @@ TEST_F(WordCheckerTest, Tokenize_SpecialCharacters) { EXPECT_TRUE(tokens.empty()); } + +TEST_F(WordCheckerTest, TokenizeUnicodeAndEmoji) { + std::string text = "привет😀мир"; + auto tokens = WordChecker::Tokenize(text); + + ASSERT_EQ(tokens.size(), 2U); + EXPECT_EQ(tokens[0], "привет"); + EXPECT_EQ(tokens[1], "мир"); +} + +TEST_F(WordCheckerTest, TokenizeEmojiBetweenAsciiWords) { + std::string text = "hello😀world"; + auto tokens = WordChecker::Tokenize(text); + + ASSERT_EQ(tokens.size(), 2U); + EXPECT_EQ(tokens[0], "hello"); + EXPECT_EQ(tokens[1], "world"); +} + +TEST_F(WordCheckerTest, WordCheckingExactlyAtThresholdIsFalse) { + std::vector at_threshold = {"gay", "gay", "gay"}; + EXPECT_FALSE(WordChecker::WordChecking(at_threshold)); +} + +TEST_F(WordCheckerTest, WordCheckingNoForbiddenWords) { + std::vector safe = {"hello", "world", "test"}; + EXPECT_FALSE(WordChecker::WordChecking(safe)); +} diff --git a/tools/common.just b/tools/common.just index ac58f66..20cbcda 100644 --- a/tools/common.just +++ b/tools/common.just @@ -2,6 +2,8 @@ MKDIR := 'mkdir -p' RM := 'rm -rf ' + TMP_DIR DOWN := 'curl -fsSL' DOWN_OUT := '-o' +EXE := if os() == "windows" { ".exe" } else { "" } +TEST_DIR := if os() == "windows" { "./build/tests/Release" } else { "./build/tests" } fetch-proto: {{MKDIR}} "{{TMP_DIR}}" @@ -83,12 +85,12 @@ upgrade-python-tools: pipx upgrade cmakelang test: - ./build/tests/Release/fake_repository_test.exe - ./build/tests/Release/handler_test.exe - ./build/tests/Release/moderation_service_test.exe - ./build/tests/Release/text_normalization_test.exe - ./build/tests/Release/text_processor_test.exe - ./build/tests/Release/word_checker_test.exe + {{TEST_DIR}}/fake_repository_test{{EXE}} + {{TEST_DIR}}/handler_test{{EXE}} + {{TEST_DIR}}/moderation_service_test{{EXE}} + {{TEST_DIR}}/text_normalization_test{{EXE}} + {{TEST_DIR}}/text_processor_test{{EXE}} + {{TEST_DIR}}/word_checker_test{{EXE}} benchmark: just build-windows