Пользователь Roman Beskrovnyi
Roman Beskrovnyi
35 уровень

"Java-проект от А до Я": Удаляем подписку на статьи из группы

Статья из группы Java-проекты
Всем привет, мои дорогие друзья, будущие Senior Software Engineers. Продолжаем разработку телеграм-бота. На этом шаге нашего проекта рассмотрим три задачи, у которых больше видимого значения, чем программного. Нам нужно научиться удалять подписку на новые статьи из определенной группы: при помощи команды /stop деактивировать бота, а при помощи команды /start — активировать. Причем так, чтобы все запросы и обновления касались только активных пользователей бота. Как обычно, обновим main ветку, чтобы получить все изменения, и создадим новую: STEP_7_JRTB-7. В этой части поговорим об удалении подписки и рассмотрим 5 вариантов событий — будет интересно.

JRTB-7: удаление подписки на новые статьи из группы

Ясное дело, что всем пользователям захочется иметь возможность удалить подписку, чтобы не получать уведомления о новых статьях. Его логика будет очень похожа на логику добавления подписки. Если мы передаем только одну команду, в ответ нам придет список групп и их ID, на которые пользователь уже подписан, чтобы можно было понять, что именно нужно удалить. А если пользователь вместе с командой передаст ID группы, мы удалим подписку. Поэтому пойдем разрабатывать эту команду со стороны телеграм-бота.
  1. Добавим имя новой команды — /deleteGroupSub, а в CommandName — строку:

    
    DELETE_GROUP_SUB("/deleteGroupSub")
    

  2. Далее создадим команду DeleteGroupSubCommand:

    
    package com.github.javarushcommunity.jrtb.command;
    
    import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
    import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
    import com.github.javarushcommunity.jrtb.service.GroupSubService;
    import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
    import com.github.javarushcommunity.jrtb.service.TelegramUserService;
    import org.springframework.util.CollectionUtils;
    import org.telegram.telegrambots.meta.api.objects.Update;
    
    import javax.ws.rs.NotFoundException;
    import java.util.List;
    import java.util.Optional;
    import java.util.stream.Collectors;
    
    import static com.github.javarushcommunity.jrtb.command.CommandName.DELETE_GROUP_SUB;
    import static com.github.javarushcommunity.jrtb.command.CommandUtils.getChatId;
    import static com.github.javarushcommunity.jrtb.command.CommandUtils.getMessage;
    import static java.lang.String.format;
    import static org.apache.commons.lang3.StringUtils.SPACE;
    import static org.apache.commons.lang3.StringUtils.isNumeric;
    
    /**
    * Delete Group subscription {@link Command}.
    */
    public class DeleteGroupSubCommand implements Command {
    
       private final SendBotMessageService sendBotMessageService;
       private final TelegramUserService telegramUserService;
       private final GroupSubService groupSubService;
    
       public DeleteGroupSubCommand(SendBotMessageService sendBotMessageService, GroupSubService groupSubService,
                                    TelegramUserService telegramUserService) {
           this.sendBotMessageService = sendBotMessageService;
           this.groupSubService = groupSubService;
           this.telegramUserService = telegramUserService;
       }
    
       @Override
       public void execute(Update update) {
           if (getMessage(update).equalsIgnoreCase(DELETE_GROUP_SUB.getCommandName())) {
               sendGroupIdList(getChatId(update));
               return;
           }
           String groupId = getMessage(update).split(SPACE)[1];
           String chatId = getChatId(update);
           if (isNumeric(groupId)) {
               Optional<GroupSub> optionalGroupSub = groupSubService.findById(Integer.valueOf(groupId));
               if (optionalGroupSub.isPresent()) {
                   GroupSub groupSub = optionalGroupSub.get();
                   TelegramUser telegramUser = telegramUserService.findByChatId(chatId).orElseThrow(NotFoundException::new);
                   groupSub.getUsers().remove(telegramUser);
                   groupSubService.save(groupSub);
                   sendBotMessageService.sendMessage(chatId, format("Удалил подписку на группу: %s", groupSub.getTitle()));
               } else {
                   sendBotMessageService.sendMessage(chatId, "Не нашел такой группы =/");
               }
           } else {
               sendBotMessageService.sendMessage(chatId, "неправильный формат ID группы.\n " +
                       "ID должно быть целым положительным числом");
           }
       }
    
       private void sendGroupIdList(String chatId) {
           String message;
           List<GroupSub> groupSubs = telegramUserService.findByChatId(chatId)
                   .orElseThrow(NotFoundException::new)
                   .getGroupSubs();
           if (CollectionUtils.isEmpty(groupSubs)) {
               message = "Пока нет подписок на группы. Чтобы добавить подписку напиши /addGroupSub";
           } else {
               message = "Чтобы удалить подписку на группу - передай комадну вместе с ID группы. \n" +
                       "Например: /deleteGroupSub 16 \n\n" +
                       "я подготовил список всех групп, на которые ты подписан) \n\n" +
                       "имя группы - ID группы \n\n" +
                       "%s";
    
           }
           String userGroupSubData = groupSubs.stream()
                   .map(group -> format("%s - %s \n", group.getTitle(), group.getId()))
                   .collect(Collectors.joining());
    
           sendBotMessageService.sendMessage(chatId, format(message, userGroupSubData));
       }
    }
    

Для этого пришлось добавить еще два метода для работы с GroupSub сущностью — получение из БД по ID и сохранение самой сущности. Все эти методы просто вызывают уже готовые методы репозитория. Отдельно расскажу об удалении подписки. В схеме базы данных это та таблица, которая отвечает за many-to-many процесс, и чтобы удалить эту связь, нужно удалить в ней запись. Это если пользоваться общим пониманием со стороны БД. Но мы используем Spring Data и там по умолчанию стоит Hibernate, который умеет это делать иначе. Мы получаем сущность GroupSub, к которой подтянутся все пользователи, которые с ней связаны. Из этой коллекции пользователей удалим нужного нам и обратно сохраним groupSub в БД, но уже без этого пользователя. Таким образом Spring Data поймет, что мы хотели, и удалит запись."Java-проект от А до Я": Удаляем подписку на статьи из группы - 1"Java-проект от А до Я": Удаляем подписку на статьи из группы - 2Чтобы быстро удалить пользователя я добавил аннотацию EqualsAndHashCode для TelegramUser, исключив список GroupSub, чтобы не было каких-либо проблем. И вызвал у коллекции пользователей метод remove с нужным нам пользователем. Вот как это выглядит у TelegramUser:

@Data
@Entity
@Table(name = "tg_user")
@EqualsAndHashCode(exclude = "groupSubs")
public class TelegramUser {

   @Id
   @Column(name = "chat_id")
   private String chatId;

   @Column(name = "active")
   private boolean active;

   @ManyToMany(mappedBy = "users", fetch = FetchType.EAGER)
   private List<GroupSub> groupSubs;
}
В результате все взлетело, как и мы хотели. В команде есть несколько вариантов событий, поэтому написать хороший тест на каждый из них — это отличная идея. К слову о тестах: пока писал их, нашел дефект в логике и исправил его еще до выхода в прод. Не было бы теста — неясно, как быстро обнаружил бы его. DeleteGroupSubCommandTest:

package com.github.javarushcommunity.jrtb.command;

import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import com.github.javarushcommunity.jrtb.service.GroupSubService;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import com.github.javarushcommunity.jrtb.service.TelegramUserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.telegram.telegrambots.meta.api.objects.Update;

import java.util.ArrayList;
import java.util.Optional;

import static com.github.javarushcommunity.jrtb.command.AbstractCommandTest.prepareUpdate;
import static com.github.javarushcommunity.jrtb.command.CommandName.DELETE_GROUP_SUB;
import static java.util.Collections.singletonList;

@DisplayName("Unit-level testing for DeleteGroupSubCommand")
class DeleteGroupSubCommandTest {

   private Command command;
   private SendBotMessageService sendBotMessageService;
   GroupSubService groupSubService;
   TelegramUserService telegramUserService;


   @BeforeEach
   public void init() {
       sendBotMessageService = Mockito.mock(SendBotMessageService.class);
       groupSubService = Mockito.mock(GroupSubService.class);
       telegramUserService = Mockito.mock(TelegramUserService.class);

       command = new DeleteGroupSubCommand(sendBotMessageService, groupSubService, telegramUserService);
   }

   @Test
   public void shouldProperlyReturnEmptySubscriptionList() {
       //given
       Long chatId = 23456L;
       Update update = prepareUpdate(chatId, DELETE_GROUP_SUB.getCommandName());

       Mockito.when(telegramUserService.findByChatId(String.valueOf(chatId)))
               .thenReturn(Optional.of(new TelegramUser()));

       String expectedMessage = "Пока нет подписок на группы. Чтобы добавить подписку напиши /addGroupSub";

       //when
       command.execute(update);

       //then
       Mockito.verify(sendBotMessageService).sendMessage(chatId.toString(), expectedMessage);
   }

   @Test
   public void shouldProperlyReturnSubscriptionLit() {
       //given
       Long chatId = 23456L;
       Update update = prepareUpdate(chatId, DELETE_GROUP_SUB.getCommandName());
       TelegramUser telegramUser = new TelegramUser();
       GroupSub gs1 = new GroupSub();
       gs1.setId(123);
       gs1.setTitle("GS1 Title");
       telegramUser.setGroupSubs(singletonList(gs1));
       Mockito.when(telegramUserService.findByChatId(String.valueOf(chatId)))
               .thenReturn(Optional.of(telegramUser));

       String expectedMessage = "Чтобы удалить подписку на группу - передай комадну вместе с ID группы. \n" +
               "Например: /deleteGroupSub 16 \n\n" +
               "я подготовил список всех групп, на которые ты подписан) \n\n" +
               "имя группы - ID группы \n\n" +
               "GS1 Title - 123 \n";

       //when
       command.execute(update);

       //then
       Mockito.verify(sendBotMessageService).sendMessage(chatId.toString(), expectedMessage);
   }

   @Test
   public void shouldRejectByInvalidGroupId() {
       //given
       Long chatId = 23456L;
       Update update = prepareUpdate(chatId, String.format("%s %s", DELETE_GROUP_SUB.getCommandName(), "groupSubId"));
       TelegramUser telegramUser = new TelegramUser();
       GroupSub gs1 = new GroupSub();
       gs1.setId(123);
       gs1.setTitle("GS1 Title");
       telegramUser.setGroupSubs(singletonList(gs1));
       Mockito.when(telegramUserService.findByChatId(String.valueOf(chatId)))
               .thenReturn(Optional.of(telegramUser));

       String expectedMessage = "неправильный формат ID группы.\n " +
               "ID должно быть целым положительным числом";

       //when
       command.execute(update);

       //then
       Mockito.verify(sendBotMessageService).sendMessage(chatId.toString(), expectedMessage);
   }

   @Test
   public void shouldProperlyDeleteByGroupId() {
       //given

       /// prepare update object
       Long chatId = 23456L;
       Integer groupId = 1234;
       Update update = prepareUpdate(chatId, String.format("%s %s", DELETE_GROUP_SUB.getCommandName(), groupId));


       GroupSub gs1 = new GroupSub();
       gs1.setId(123);
       gs1.setTitle("GS1 Title");
       TelegramUser telegramUser = new TelegramUser();
       telegramUser.setChatId(chatId.toString());
       telegramUser.setGroupSubs(singletonList(gs1));
       ArrayList<TelegramUser> users = new ArrayList<>();
       users.add(telegramUser);
       gs1.setUsers(users);
       Mockito.when(groupSubService.findById(groupId)).thenReturn(Optional.of(gs1));
       Mockito.when(telegramUserService.findByChatId(String.valueOf(chatId)))
               .thenReturn(Optional.of(telegramUser));

       String expectedMessage = "Удалил подписку на группу: GS1 Title";

       //when
       command.execute(update);

       //then
       users.remove(telegramUser);
       Mockito.verify(groupSubService).save(gs1);
       Mockito.verify(sendBotMessageService).sendMessage(chatId.toString(), expectedMessage);
   }

   @Test
   public void shouldDoesNotExistByGroupId() {
       //given
       Long chatId = 23456L;
       Integer groupId = 1234;
       Update update = prepareUpdate(chatId, String.format("%s %s", DELETE_GROUP_SUB.getCommandName(), groupId));


       Mockito.when(groupSubService.findById(groupId)).thenReturn(Optional.empty());

       String expectedMessage = "Не нашел такой группы =/";

       //when
       command.execute(update);

       //then
       Mockito.verify(groupSubService).findById(groupId);
       Mockito.verify(sendBotMessageService).sendMessage(chatId.toString(), expectedMessage);
   }
}
Здесь каждый тест проверяет отдельный сценарий, а их, напомню, всего пять:
  • когда просто передали команду /deleteGroupSub и нет подписок на группы;
  • когда просто передали команду /deleteGroupSub и есть подписки на группы;
  • когда передали невалидный ID группы, например, /deleteGroupSub abc;
  • сценарий, при котором все правильно удалится, как и ожидается;
  • сценарий, когда ID группы валидный, но такой группы нет в БД.
Как можно заметить, все эти сценарии нужно покрывать тестами. Я вот пока писал — понял, что для лучшего написания тестов стоит пройти какие-нибудь курсы тестировщиков. Думаю, это поможет правильно искать разные варианты. Это так, мысли на будущее. Далее нужно добавить в /help команде описание того, что теперь можно удалять подписку. Поместим ее в секции работы с подписками."Java-проект от А до Я": Удаляем подписку на статьи из группы - 3Разумеется, чтобы эта команда заработала, нужно добавить ее инициализацию в CommandContainer:

.put(DELETE_GROUP_SUB.getCommandName(),
       new DeleteGroupSubCommand(sendBotMessageService, groupSubService, telegramUserService))
Теперь можно тестировать функционал уже на тестовом боте. Запускаем нашу базу, используя docker-compose-test.yml: docker-compose -f docker-compose-test.yml up И через IDEA запускаем SpringBoot. Полностью очищу переписку с ботом и начну заново. Прогоню все варианты, которые могут возникнуть при работе с этой командой."Java-проект от А до Я": Удаляем подписку на статьи из группы - 4Как видно из скриншота, все варианты прошли и прошли успешно.
Друзья! Хотите сразу узнавать, когда выйдет новый код по проекту? Когда выходит новая статья? Присоединяйтесь к моему Телеграм-каналу. Там я собираю свою статьи, мысли и open source разработку воедино.
Обновляем версию нашего проекта на 0.6.0-SNAPSHOT Обновляем RELEASE_NOTES.md, добавляя описание того, что сделано в новой версии:
## 0.6.0-SNAPSHOT * JRTB-7: added the ability to delete group subscription.
Код работает, тесты на него написаны: пришла пора пушить задачу в репозиторий и создавать пул-реквест."Java-проект от А до Я": Удаляем подписку на статьи из группы - 5

Вместо окончания

Давно мы не смотрели на наш борд проекта, а ведь там произошли большие изменения:"Java-проект от А до Я": Удаляем подписку на статьи из группы - 6Осталось всего 5 задач. То есть, мы с вами уже в самом конце пути. Осталось немного. Особенно его посмотреть, что эта серия статей идет с середины сентября, то есть на протяжении 7 месяцев!!! Я когда придумал эту идею, не ожидал, что будет тааак долго. Вместе с тем, результатом я доволен более чем! Друзья, если не понятно, что происходит в статье, задавайте вопросы в комментариях. Так я буду знать, что что-то стоит получше описать, а что-то — дополнительно объяснить. Ну и как обычно лайк - подписка - колокольчик, ставьте звезду нашему проекту, пишите комментарии и оценивайте статью! Всем спасибо. До следующей части. Скоро поговорим о том, как добавить деактивацию и активацию бота через команды /stop & /start и том, как их лучше использовать. До скорого!"Java-проект от А до Я": Удаляем подписку на статьи из группы - 7

Список всех материалов серии в начале этой статьи.

Комментарии (5)
Чтобы просмотреть все комментарии или оставить свой,
перейдите в полную версию
Dmitry Nevar 26 уровень, Минск
26 апреля 2021
Роман, приветствую, у меня вопрос. Почему когда я в enum CommandName: ADD_GROUP_SUB("/addgroupsub"), LIST_GROUP_SUB("/listgroupsub"), DELETE_GROUP_SUB("/deletegroupsub") Пишу через camelCase: ADD_GROUP_SUB("/addGroupSub"), LIST_GROUP_SUB("/listGroupSub"), DELETE_GROUP_SUB("/deleteGroupSub") бот перестает реагировать на них и выводит ответ что нее знает такой команды
Andrey Babichev 41 уровень, Tallinn
24 апреля 2021
Как всегда, спасибо за отличный материал!