#include "controllers/accounts/AccountController.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandController.hpp" #include "controllers/commands/common/ChannelAction.hpp" #include "mocks/BaseApplication.hpp" #include "mocks/Emotes.hpp" #include "mocks/Helix.hpp" #include "mocks/Logging.hpp" #include "mocks/TwitchIrcServer.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Settings.hpp" #include "Test.hpp" #include using namespace chatterino; using ::testing::_; using ::testing::StrictMock; namespace { class MockApplication : public mock::BaseApplication { public: MockApplication() : commands(this->paths_) { } ITwitchIrcServer *getTwitch() override { return &this->twitch; } AccountController *getAccounts() override { return &this->accounts; } CommandController *getCommands() override { return &this->commands; } IEmotes *getEmotes() override { return &this->emotes; } ILogging *getChatLogger() override { return &this->chatLogger; } mock::EmptyLogging chatLogger; AccountController accounts; CommandController commands; mock::MockTwitchIrcServer twitch; mock::Emotes emotes; }; } // namespace namespace chatterino { TEST(Commands, parseBanActions) { MockApplication app; std::shared_ptr channel = std::make_shared("forsen"); CommandContext ctx{}; QString command("/ban"); QString usage("usage string"); bool withDuration = false; bool withReason = true; struct Test { CommandContext inputContext; std::vector expectedActions; QString expectedError; }; std::vector tests{ { // Normal ban with an added reason, with the user maybe trying to use the --channel parameter at the end, but it gets eaten by the reason .inputContext = { .words = {"/ban", "forsen", "the", "ban", "reason", "--channel", "xD"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "forsen", .displayName = "forsen", }, .target = { .login = "forsen", }, .reason = "the ban reason --channel xD", .duration = 0, }, }, .expectedError = "", }, { // Normal ban with an added reason .inputContext = { .words = {"/ban", "forsen", "the", "ban", "reason"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "forsen", .displayName = "forsen", }, .target = { .login = "forsen", }, .reason = "the ban reason", .duration = 0, }, }, .expectedError = "", }, { // Normal ban without an added reason .inputContext = { .words = {"/ban", "forsen"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "forsen", .displayName = "forsen", }, .target = { .login = "forsen", }, .reason = "", .duration = 0, }, }, .expectedError = "", }, { // User forgot to specify who to ban .inputContext = { .words = {"/ban"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = {}, .expectedError = "Missing target - " % usage, }, { // User tried to use /ban outside of a channel context (shouldn't really be able to happen) .inputContext = { .words = {"/ban"}, }, .expectedActions = {}, .expectedError = "A " % command % " action must be performed with a channel as a context", }, { // User tried to use /ban without a target, but with a --channel specified .inputContext = { .words = {"/ban", "--channel", "pajlada"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = {}, .expectedError = "Missing target - " % usage, }, { // User overriding the ban to be done in the pajlada channel .inputContext = { .words = {"/ban", "--channel", "pajlada", "forsen"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "pajlada", }, .target = { .login = "forsen", }, .reason = "", .duration = 0, }, }, .expectedError = "", }, { // User overriding the ban to be done in the pajlada channel and in the channel with the id 11148817 .inputContext = { .words = {"/ban", "--channel", "pajlada", "--channel", "id:11148817", "forsen"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "pajlada", }, .target = { .login = "forsen", }, .reason = "", .duration = 0, }, { .channel = { .id = "11148817", }, .target = { .login = "forsen", }, .reason = "", .duration = 0, }, }, .expectedError = "", }, { // User overriding the ban to be done in the pajlada channel and in the channel with the id 11148817, with a reason specified .inputContext = { .words = {"/ban", "--channel", "pajlada", "--channel", "id:11148817", "forsen", "the", "ban", "reason"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "pajlada", }, .target = { .login = "forsen", }, .reason = "the ban reason", .duration = 0, }, { .channel = { .id = "11148817", }, .target = { .login = "forsen", }, .reason = "the ban reason", .duration = 0, }, }, .expectedError = "", }, }; for (const auto &test : tests) { auto oActions = commands::parseChannelAction( test.inputContext, command, usage, withDuration, withReason); if (!test.expectedActions.empty()) { ASSERT_TRUE(oActions.has_value()) << oActions.error(); auto actions = *oActions; ASSERT_EQ(actions.size(), test.expectedActions.size()); ASSERT_EQ(actions, test.expectedActions); } else { ASSERT_FALSE(oActions.has_value()); } if (!test.expectedError.isEmpty()) { ASSERT_FALSE(oActions.has_value()); ASSERT_EQ(oActions.error(), test.expectedError); } } } TEST(Commands, parseTimeoutActions) { MockApplication app; std::shared_ptr channel = std::make_shared("forsen"); CommandContext ctx{}; QString command("/timeout"); QString usage("usage string"); bool withDuration = true; bool withReason = true; struct Test { CommandContext inputContext; std::vector expectedActions; QString expectedError; }; std::vector tests{ { // Normal timeout without an added reason, with the default duration .inputContext = { .words = {"/timeout", "forsen"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "forsen", .displayName = "forsen", }, .target = { .login = "forsen", }, .reason = "", .duration = 10 * 60, }, }, .expectedError = "", }, { // Normal timeout without an added reason, with a custom duration .inputContext = { .words = {"/timeout", "forsen", "5m"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "forsen", .displayName = "forsen", }, .target = { .login = "forsen", }, .reason = "", .duration = 5 * 60, }, }, .expectedError = "", }, { // Normal timeout without an added reason, with a custom duration, with an added reason .inputContext = { .words = {"/timeout", "forsen", "5m", "the", "timeout", "reason"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "forsen", .displayName = "forsen", }, .target = { .login = "forsen", }, .reason = "the timeout reason", .duration = 5 * 60, }, }, .expectedError = "", }, { // Normal timeout without an added reason, with an added reason, but user forgot to specify a timeout duration so it fails .inputContext = { .words = {"/timeout", "forsen", "the", "timeout", "reason"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = {}, .expectedError = "Invalid duration - " % usage, }, { // User forgot to specify who to timeout .inputContext = { .words = {"/timeout"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = {}, .expectedError = "Missing target - " % usage, }, { // User tried to use /timeout outside of a channel context (shouldn't really be able to happen) .inputContext = { .words = {"/timeout"}, }, .expectedActions = {}, .expectedError = "A " % command % " action must be performed with a channel as a context", }, { // User tried to use /timeout without a target, but with a --channel specified .inputContext = { .words = {"/timeout", "--channel", "pajlada"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = {}, .expectedError = "Missing target - " % usage, }, { // User overriding the timeout to be done in the pajlada channel .inputContext = { .words = {"/timeout", "--channel", "pajlada", "forsen"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "pajlada", }, .target = { .login = "forsen", }, .reason = "", .duration = 10 * 60, }, }, .expectedError = "", }, { // User overriding the timeout to be done in the pajlada channel and in the channel with the id 11148817 .inputContext = { .words = {"/timeout", "--channel", "pajlada", "--channel", "id:11148817", "forsen"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "pajlada", }, .target = { .login = "forsen", }, .reason = "", .duration = 10 * 60, }, { .channel = { .id = "11148817", }, .target = { .login = "forsen", }, .reason = "", .duration = 10 * 60, }, }, .expectedError = "", }, { // User overriding the timeout to be done in the pajlada channel and in the channel with the id 11148817, with a custom duration .inputContext = { .words = {"/timeout", "--channel", "pajlada", "--channel", "id:11148817", "forsen", "5m"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "pajlada", }, .target = { .login = "forsen", }, .reason = "", .duration = 5 * 60, }, { .channel = { .id = "11148817", }, .target = { .login = "forsen", }, .reason = "", .duration = 5 * 60, }, }, .expectedError = "", }, { // User overriding the timeout to be done in the pajlada channel and in the channel with the id 11148817, with a reason specified .inputContext = { .words = {"/timeout", "--channel", "pajlada", "--channel", "id:11148817", "forsen", "10m", "the", "timeout", "reason"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "pajlada", }, .target = { .login = "forsen", }, .reason = "the timeout reason", .duration = 10 * 60, }, { .channel = { .id = "11148817", }, .target = { .login = "forsen", }, .reason = "the timeout reason", .duration = 10 * 60, }, }, .expectedError = "", }, }; for (const auto &test : tests) { auto oActions = commands::parseChannelAction( test.inputContext, command, usage, withDuration, withReason); if (!test.expectedActions.empty()) { ASSERT_TRUE(oActions.has_value()) << oActions.error(); auto actions = *oActions; ASSERT_EQ(actions.size(), test.expectedActions.size()); ASSERT_EQ(actions, test.expectedActions); } else { ASSERT_FALSE(oActions.has_value()); } if (!test.expectedError.isEmpty()) { ASSERT_FALSE(oActions.has_value()); ASSERT_EQ(oActions.error(), test.expectedError); } } } TEST(Commands, parseUnbanActions) { MockApplication app; std::shared_ptr channel = std::make_shared("forsen"); CommandContext ctx{}; QString command("/unban"); QString usage("usage string"); bool withDuration = false; bool withReason = false; struct Test { CommandContext inputContext; std::vector expectedActions; QString expectedError; }; std::vector tests{ { // Normal unban .inputContext = { .words = {"/unban", "forsen"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "forsen", .displayName = "forsen", }, .target = { .login = "forsen", }, }, }, .expectedError = "", }, { // Normal unban but user input some random junk after the target .inputContext = { .words = {"/unban", "forsen", "foo", "bar", "baz"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "forsen", .displayName = "forsen", }, .target = { .login = "forsen", }, }, }, .expectedError = "", }, { // User forgot to specify who to unban .inputContext = { .words = {"/unban"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = {}, .expectedError = "Missing target - " % usage, }, { // User tried to use /unban outside of a channel context (shouldn't really be able to happen) .inputContext = { .words = {"/unban"}, }, .expectedActions = {}, .expectedError = "A " % command % " action must be performed with a channel as a context", }, { // User tried to use /unban without a target, but with a --channel specified .inputContext = { .words = {"/unban", "--channel", "pajlada"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = {}, .expectedError = "Missing target - " % usage, }, { // User overriding the unban to be done in the pajlada channel .inputContext = { .words = {"/unban", "--channel", "pajlada", "forsen"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "pajlada", }, .target = { .login = "forsen", }, }, }, .expectedError = "", }, { // User overriding the unban to be done in the pajlada channel and in the channel with the id 11148817 .inputContext = { .words = {"/unban", "--channel", "pajlada", "--channel", "id:11148817", "forsen"}, .channel = channel, .twitchChannel = channel.get(), }, .expectedActions = { { .channel = { .login = "pajlada", }, .target = { .login = "forsen", }, }, { .channel = { .id = "11148817", }, .target = { .login = "forsen", }, }, }, .expectedError = "", }, }; for (const auto &test : tests) { auto oActions = commands::parseChannelAction( test.inputContext, command, usage, withDuration, withReason); if (!test.expectedActions.empty()) { ASSERT_TRUE(oActions.has_value()) << oActions.error(); auto actions = *oActions; ASSERT_EQ(actions.size(), test.expectedActions.size()); ASSERT_EQ(actions, test.expectedActions); } else { ASSERT_FALSE(oActions.has_value()); } if (!test.expectedError.isEmpty()) { ASSERT_FALSE(oActions.has_value()); ASSERT_EQ(oActions.error(), test.expectedError); } } } TEST(Commands, E2E) { ::testing::InSequence seq; MockApplication app; QJsonObject pajlada; pajlada["id"] = "11148817"; pajlada["login"] = "pajlada"; pajlada["display_name"] = "pajlada"; pajlada["created_at"] = "2010-03-17T11:50:53Z"; pajlada["description"] = " ͡° ͜ʖ ͡°)"; pajlada["profile_image_url"] = "https://static-cdn.jtvnw.net/jtv_user_pictures/" "cbe986e3-06ad-4506-a3aa-eb05466c839c-profile_image-300x300.png"; QJsonObject testaccount420; testaccount420["id"] = "117166826"; testaccount420["login"] = "testaccount_420"; testaccount420["display_name"] = "테스트계정420"; testaccount420["created_at"] = "2016-02-27T18:55:59Z"; testaccount420["description"] = ""; testaccount420["profile_image_url"] = "https://static-cdn.jtvnw.net/user-default-pictures-uv/" "ead5c8b2-a4c9-4724-b1dd-9f00b46cbd3d-profile_image-300x300.png"; QJsonObject forsen; forsen["id"] = "22484632"; forsen["login"] = "forsen"; forsen["display_name"] = "Forsen"; forsen["created_at"] = "2011-05-19T00:28:28Z"; forsen["description"] = "Approach with caution! No roleplaying or tryharding allowed."; forsen["profile_image_url"] = "https://static-cdn.jtvnw.net/jtv_user_pictures/" "forsen-profile_image-48b43e1e4f54b5c8-300x300.png"; std::shared_ptr channel = std::make_shared("pajlada"); channel->setRoomId("11148817"); StrictMock mockHelix; initializeHelix(&mockHelix); EXPECT_CALL(mockHelix, update).Times(1); EXPECT_CALL(mockHelix, loadBlocks).Times(1); auto account = std::make_shared( testaccount420["login"].toString(), "token", "oauthclient", testaccount420["id"].toString()); getApp()->getAccounts()->twitch.accounts.append(account); getApp()->getAccounts()->twitch.currentUsername = testaccount420["login"].toString(); getApp()->getAccounts()->twitch.load(); // Simple single-channel ban EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, QStringList{"forsen"}, _, _)) .WillOnce([=](auto, auto, auto success, auto) { std::vector users{ HelixUser(pajlada), HelixUser(forsen), }; success(users); }); EXPECT_CALL(mockHelix, banUser(pajlada["id"].toString(), QString("117166826"), forsen["id"].toString(), std::optional{}, QString(""), _, _)) .Times(1); getApp()->getCommands()->execCommand("/ban forsen", channel, false); // Multi-channel ban EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, QStringList{"forsen"}, _, _)) .WillOnce([=](auto, auto, auto success, auto) { std::vector users{ HelixUser(pajlada), HelixUser(forsen), }; success(users); }); EXPECT_CALL(mockHelix, banUser(pajlada["id"].toString(), testaccount420["id"].toString(), forsen["id"].toString(), std::optional{}, QString(""), _, _)) .Times(1); EXPECT_CALL(mockHelix, fetchUsers(QStringList{}, QStringList{"forsen", "testaccount_420"}, _, _)) .WillOnce([=](auto, auto, auto success, auto) { std::vector users{ HelixUser(testaccount420), HelixUser(forsen), }; success(users); }); EXPECT_CALL(mockHelix, banUser(testaccount420["id"].toString(), testaccount420["id"].toString(), forsen["id"].toString(), std::optional{}, QString(""), _, _)) .Times(1); getApp()->getCommands()->execCommand( "/ban --channel id:11148817 --channel testaccount_420 forsen", channel, false); // ID-based ban EXPECT_CALL(mockHelix, banUser(pajlada["id"].toString(), QString("117166826"), forsen["id"].toString(), std::optional{}, QString(""), _, _)) .Times(1); getApp()->getCommands()->execCommand("/ban id:22484632", channel, false); // ID-based redirected ban EXPECT_CALL(mockHelix, banUser(testaccount420["id"].toString(), QString("117166826"), forsen["id"].toString(), std::optional{}, QString(""), _, _)) .Times(1); getApp()->getCommands()->execCommand( "/ban --channel id:117166826 id:22484632", channel, false); // name-based redirected ban EXPECT_CALL(mockHelix, fetchUsers(QStringList{"22484632"}, QStringList{"testaccount_420"}, _, _)) .WillOnce([=](auto, auto, auto success, auto) { std::vector users{ HelixUser(testaccount420), HelixUser(forsen), }; success(users); }); EXPECT_CALL(mockHelix, banUser(testaccount420["id"].toString(), QString("117166826"), forsen["id"].toString(), std::optional{}, QString(""), _, _)) .Times(1); getApp()->getCommands()->execCommand( "/ban --channel testaccount_420 id:22484632", channel, false); // Multi-channel timeout EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, QStringList{"forsen"}, _, _)) .WillOnce([=](auto, auto, auto success, auto) { std::vector users{ HelixUser(pajlada), HelixUser(forsen), }; success(users); }); EXPECT_CALL(mockHelix, banUser(pajlada["id"].toString(), testaccount420["id"].toString(), forsen["id"].toString(), std::optional{600}, QString(""), _, _)) .Times(1); EXPECT_CALL(mockHelix, fetchUsers(QStringList{}, QStringList{"forsen", "testaccount_420"}, _, _)) .WillOnce([=](auto, auto, auto success, auto) { std::vector users{ HelixUser(testaccount420), HelixUser(forsen), }; success(users); }); EXPECT_CALL(mockHelix, banUser(testaccount420["id"].toString(), testaccount420["id"].toString(), forsen["id"].toString(), std::optional{600}, QString(""), _, _)) .Times(1); getApp()->getCommands()->execCommand( "/timeout --channel id:11148817 --channel testaccount_420 forsen", channel, false); // Multi-channel unban EXPECT_CALL(mockHelix, fetchUsers(QStringList{"11148817"}, QStringList{"forsen"}, _, _)) .WillOnce([=](auto, auto, auto success, auto) { std::vector users{ HelixUser(pajlada), HelixUser(forsen), }; success(users); }); EXPECT_CALL(mockHelix, unbanUser(pajlada["id"].toString(), testaccount420["id"].toString(), forsen["id"].toString(), _, _)) .Times(1); EXPECT_CALL(mockHelix, fetchUsers(QStringList{}, QStringList{"forsen", "testaccount_420"}, _, _)) .WillOnce([=](auto, auto, auto success, auto) { std::vector users{ HelixUser(testaccount420), HelixUser(forsen), }; success(users); }); EXPECT_CALL(mockHelix, unbanUser(testaccount420["id"].toString(), testaccount420["id"].toString(), forsen["id"].toString(), _, _)) .Times(1); getApp()->getCommands()->execCommand( "/unban --channel id:11148817 --channel testaccount_420 forsen", channel, false); } } // namespace chatterino