JavaRush /Java блог /Random UA /Видаляємо передплату на статті з групи - "Java-проект від...
Roman Beekeeper
35 рівень

Видаляємо передплату на статті з групи - "Java-проект від А до Я"

Стаття з групи Random UA
Всім привіт, мої дорогі друзі майбутні 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.codegymcommunity.jrtb.command;
    
    import com.github.codegymcommunity.jrtb.repository.entity.GroupSub;
    import com.github.codegymcommunity.jrtb.repository.entity.TelegramUser;
    import com.github.codegymcommunity.jrtb.service.GroupSubService;
    import com.github.codegymcommunity.jrtb.service.SendBotMessageService;
    import com.github.codegymcommunity.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.codegymcommunity.jrtb.command.CommandName.DELETE_GROUP_SUB;
    import static com.github.codegymcommunity.jrtb.command.CommandUtils.getChatId;
    import static com.github.codegymcommunity.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.codegymcommunity.jrtb.command;

import com.github.codegymcommunity.jrtb.repository.entity.GroupSub;
import com.github.codegymcommunity.jrtb.repository.entity.TelegramUser;
import com.github.codegymcommunity.jrtb.service.GroupSubService;
import com.github.codegymcommunity.jrtb.service.SendBotMessageService;
import com.github.codegymcommunity.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.codegymcommunity.jrtb.command.AbstractCommandTest.prepareUpdate;
import static com.github.codegymcommunity.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 ability до delete group subscription.
Код працює, тести на нього написані: настав час пушити завдання в репозиторій і створювати пул-реквест."Java-проект від А до Я": Видаляємо передплату на статті з групи - 5

Замість закінчення

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

Список всіх матеріалів серії на початку цієї статті.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ