JavaRush /Java Blog /Random EN /We add the ability to subscribe to a group of articles. (...

We add the ability to subscribe to a group of articles. (Part 2) - "Java project from A to Z"

Published in the Random EN group
Hi all! We continue to work on the task we started last week ."Java project from A to Z": Adding the ability to subscribe to a group of articles.  Part 2 - 1

Implementing JRTB-5

Now we need to add a command so that we can subscribe to some article group from CodeGym. How to do it? We will follow the simplest scenario that I came up with. Since we have access by group ID, we need the user to transfer it. To do this, the user will enter the /addGroupSub GROUP_ID command, which will work in one of two ways: if only the command itself comes: /addGroupSub , a list of all groups and their IDs are sent in response. Then the user will be able to select the group ID he needs and compose the second query option in this command: /addGroupSubGROUP_ID - and then there will already be a record of this group with this user. I think we can do better in the future. Our goal is to show exactly the development, and not a super cool user experience (I’m ashamed to say, but I don’t know the term in Russian that would mean this). To correctly add functionality that goes through the entire application (in our case, from the telegram bot client to the database), you need to start from some end. We will do it from the database side.

Adding a new migration to the database

The first thing to do is to add a new database migration and the ability to save user group subscription data in JR. To remember how it should be, return to the article “ Project planning: measure seven times ”. There, on the second photo, an approximate database diagram is drawn. We need to add a table to store group information:
  • The group ID in CodeGym will also be our ID. We trust them and believe that these IDs are unique;
  • title - in our pictures it was name - the informal name of the group; that is, what we see on the CodeGym site;
  • last_article_id - and this is an interesting field. It will store the last article ID in this group, which the bot has already sent to its subscribers. This field will be used to search for new articles. New subscribers will not receive articles published before the user subscribed: only those published after subscribing to the group.
We will also have a many-to-many relationship between the groups and users table, because each user can have many subscriptions to groups (one-to-many), and each subscription to a group can have many users (one-to- many, only on the other hand). It turns out that this will be our many-to-many. For whom this raises questions, review the articles for databases. Yes, soon I plan to create a post in the Telegram channel, where I will put together all the articles on the database. Here's what our second database migration will look like.
V00002__created_groupsub_many_to_many.sql:

-- add PRIMARY KEY FOR tg_user
ALTER TABLE tg_user ADD PRIMARY KEY (chat_id);

-- ensure that the tables with these names are removed before creating a new one.
DROP TABLE IF EXISTS group_sub;
DROP TABLE IF EXISTS group_x_user;

CREATE TABLE group_sub (
   id INT,
   title VARCHAR(100),
   last_article_id INT,
   PRIMARY KEY (id)
);

CREATE TABLE group_x_user (
   group_sub_id INT NOT NULL,
   user_id VARCHAR(100) NOT NULL,
   FOREIGN KEY (user_id) REFERENCES tg_user(chat_id),
   FOREIGN KEY (group_sub_id) REFERENCES group_sub(id),
   UNIQUE(user_id, group_sub_id)
);
It is important to note that first I change the old table - I add a primary key to it. I somehow missed it at that time, but now MySQL did not give me the opportunity to add a FOREIGN KEY for the gorup_x_user table, and I updated the database as part of this migration. Pay attention to an important aspect. Changing the database should be done in this way - in the new migration everything that is needed, but not by updating the already released migration. Yes, in our case, nothing would have happened, since this is a test project and we know that it is deployed in only one place, but this would be the wrong approach. But we want everything to be right. Next comes dropping tables before creating them. Why is this? So that if, by some chance, tables with such names were in the database, the migration would not fall and work exactly as expected. And then add two tables. All as they wanted. Now we need to run our application. If everything starts and does not break, then the migration is recorded. And in order to double-check this, we go to the database to make sure that: a) such tables have appeared; b) there is a new entry in the flyway technical table. This completes the migration work, let's move on to the repositories.

Adding a Repository Layer

Thanks to Spring Boot Data, everything is very simple here: we need to add the GroupSub entity, update TelegramUser a bit and add an almost empty GroupSubRepository: We add the GroupSub entity to the same package as TelegramUser:
package com.github.codegymcommunity.jrtb.repository.entity;

import lombok.Data;
import lombok.EqualsAndHashCode;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

import static java.util.Objects.isNull;

@Data
@Entity
@Table(name = "group_sub")
@EqualsAndHashCode
public class GroupSub {

   @Id
   private Integer id;

   @Column(name = "title")
   private String title;

   @Column(name = "last_article_id")
   private Integer lastArticleId;

   @ManyToMany(fetch = FetchType.EAGER)
   @JoinTable(
           name = "group_x_user",
           joinColumns = @JoinColumn(name = "group_sub_id"),
           inverseJoinColumns = @JoinColumn(name = "user_id")
   )
   private List<TelegramUser> users;

   public void addUser(TelegramUser telegramUser) {
       if (isNull(users)) {
           users = new ArrayList<>();
       }
       users.add(telegramUser);
   }
}
From what is worth noting - we have an additional field users, which will contain a collection of all users subscribed to the group. And two annotations - ManyToMany and JoinTable - just for this we need. This field should be added for TelegramUser as well:
@ManyToMany(mappedBy = "users", fetch = FetchType.EAGER)
private List<GroupSub> groupSubs;
This field uses the joins written in the GroupSub entity. And, in fact, our repository class for GroupSub is GroupSubRepository :
package com.github.codegymcommunity.jrtb.repository;

import com.github.codegymcommunity.jrtb.repository.entity.GroupSub;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

/**
* {@link Repository} for {@link GroupSub} entity.
*/
@Repository
public interface GroupSubRepository extends JpaRepository<GroupSub, Integer> {
}
At this stage, we do not need additional methods: those that are implemented in the ancestor of JpaRepository are enough for us. Let's write a test in TelegramUserRepositoryIT that will check that our many-to-many works. The idea of ​​the test is that we will add 5 groups of subscriptions for one user to the database via sql script, get this user by his ID and check that exactly those groups came to us and with exactly these values. How to do it? You can sew a counter into the data, which we will then go through and check. Here is the fiveGroupSubsForUser.sql script:
INSERT INTO tg_user VALUES (1, 1);

INSERT INTO group_sub VALUES
(1, 'g1', 1),
(2, 'g2', 2),
(3, 'g3', 3),
(4, 'g4', 4),
(5, 'g5', 5);

INSERT INTO group_x_user VALUES
(1, 1),
(2, 1),
(3, 1),
(4, 1),
(5, 1);
And the test itself:
@Sql(scripts = {"/sql/clearDbs.sql", "/sql/fiveGroupSubsForUser.sql"})
@Test
public void shouldProperlyGetAllGroupSubsForUser() {
   //when
   Optional<TelegramUser> userFromDB = telegramUserRepository.findById("1");

   //then
   Assertions.assertTrue(userFromDB.isPresent());
   List<GroupSub> groupSubs = userFromDB.get().getGroupSubs();
   for (int i = 0; i < groupSubs.size(); i++) {
       Assertions.assertEquals(String.format("g%s", (i + 1)), groupSubs.get(i).getTitle());
       Assertions.assertEquals(i + 1, groupSubs.get(i).getId());
       Assertions.assertEquals(i + 1, groupSubs.get(i).getLastArticleId());
   }
}
Now let's add a test of the same meaning for the GroupSub entity. To do this, let's create a test class groupSubRepositoryIT in the same package as groupSubRepositoryIT :
package com.github.codegymcommunity.jrtb.repository;

import com.github.codegymcommunity.jrtb.repository.entity.GroupSub;
import com.github.codegymcommunity.jrtb.repository.entity.TelegramUser;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;

import java.util.List;
import java.util.Optional;

import static org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace.NONE;

/**
* Integration-level testing for {@link GroupSubRepository}.
*/
@ActiveProfiles("test")
@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
public class GroupSubRepositoryIT {

   @Autowired
   private GroupSubRepository groupSubRepository;

   @Sql(scripts = {"/sql/clearDbs.sql", "/sql/fiveUsersForGroupSub.sql"})
   @Test
   public void shouldProperlyGetAllUsersForGroupSub() {
       //when
       Optional<GroupSub> groupSubFromDB = groupSubRepository.findById(1);

       //then
       Assertions.assertTrue(groupSubFromDB.isPresent());
       Assertions.assertEquals(1, groupSubFromDB.get().getId());
       List<TelegramUser> users = groupSubFromDB.get().getUsers();
       for(int i=0; i<users.size(); i++) {
           Assertions.assertEquals(String.valueOf(i + 1), users.get(i).getChatId());
           Assertions.assertTrue(users.get(i).isActive());
       }
   }
}
And the missing fiveUsersForGroupSub.sql script:
INSERT INTO tg_user VALUES
(1, 1),
(2, 1),
(3, 1),
(4, 1),
(5, 1);

INSERT INTO group_sub VALUES (1, 'g1', 1);

INSERT INTO group_x_user VALUES
(1, 1),
(1, 2),
(1, 3),
(1, 4),
(1, 5);
On this part of the work with the repository can be considered finished. Now let's write the services layer.

Writing GroupSubService

At this stage, to work with subscription groups, we only need to be able to save them, so no problem: we create the GroupSubService service and its GroupSubServiceImpl implementation in a package that contains other services - service:
package com.github.codegymcommunity.jrtb.service;

import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupDiscussionInfo;
import com.github.codegymcommunity.jrtb.repository.entity.GroupSub;

/**
* Service for manipulating with {@link GroupSub}.
*/
public interface GroupSubService {

   GroupSub save(String chatId, GroupDiscussionInfo groupDiscussionInfo);
}
And its implementation:
package com.github.codegymcommunity.jrtb.service;

import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupDiscussionInfo;
import com.github.codegymcommunity.jrtb.repository.GroupSubRepository;
import com.github.codegymcommunity.jrtb.repository.entity.GroupSub;
import com.github.codegymcommunity.jrtb.repository.entity.TelegramUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.ws.rs.NotFoundException;
import java.util.Optional;

@Service
public class GroupSubServiceImpl implements GroupSubService {

   private final GroupSubRepository groupSubRepository;
   private final TelegramUserService telegramUserService;

   @Autowired
   public GroupSubServiceImpl(GroupSubRepository groupSubRepository, TelegramUserService telegramUserService) {
       this.groupSubRepository = groupSubRepository;
       this.telegramUserService = telegramUserService;
   }

   @Override
   public GroupSub save(String chatId, GroupDiscussionInfo groupDiscussionInfo) {
       TelegramUser telegramUser = telegramUserService.findByChatId(chatId).orElseThrow(NotFoundException::new);
       //TODO add exception handling
       GroupSub groupSub;
       Optional<GroupSub> groupSubFromDB = groupSubRepository.findById(groupDiscussionInfo.getId());
       if(groupSubFromDB.isPresent()) {
           groupSub = groupSubFromDB.get();
           Optional<TelegramUser> first = groupSub.getUsers().stream()
                   .filter(it -> it.getChatId().equalsIgnoreCase(chatId))
                   .findFirst();
           if(first.isEmpty()) {
               groupSub.addUser(telegramUser);
           }
       } else {
           groupSub = new GroupSub();
           groupSub.addUser(telegramUser);
           groupSub.setId(groupDiscussionInfo.getId());
           groupSub.setTitle(groupDiscussionInfo.getTitle());
       }
       return groupSubRepository.save(groupSub);
   }
}
In order for Spring Data to work correctly and create a many-to-many record, we need to get the user from our database for the subscription group that we are creating and add it to the GroupSub object. Thus, when we transfer this subscription for saving, a connection will also be created through the group_x_user table. There may be a situation when such a subscription group has already been created and you just need to add one more user to it. To do this, we first get the group ID from the database, and if there is a record, we work with it, if not, we create a new one. It is important to note that we use TelegramUserService to work with TelegramUser to follow the last of the SOLID principles. For now, if we don't find an entry by ID, I just throw an exception. It is not processed in any way now: we will do it at the very end, before the MVP. Let's write two unit tests for a classGroupSubServiceTest . What do we need? I want to be sure that the save method will be called in the GroupSubRepository and an entity with one single user will be passed to the GroupSub - the one that will return the TelegramUserService to us by the provided ID. And the second option is when a group with this ID already exists in the database and this group already has one user, and you need to check that another user will be added to this group and this object will be saved. Here is the implementation:
package com.github.codegymcommunity.jrtb.service;

import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupDiscussionInfo;
import com.github.codegymcommunity.jrtb.repository.GroupSubRepository;
import com.github.codegymcommunity.jrtb.repository.entity.GroupSub;
import com.github.codegymcommunity.jrtb.repository.entity.TelegramUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

import java.util.Optional;

@DisplayName("Unit-level testing for GroupSubService")
public class GroupSubServiceTest {

   private GroupSubService groupSubService;
   private GroupSubRepository groupSubRepository;
   private TelegramUser newUser;

   private final static String CHAT_ID = "1";

   @BeforeEach
   public void init() {
       TelegramUserService telegramUserService = Mockito.mock(TelegramUserService.class);
       groupSubRepository = Mockito.mock(GroupSubRepository.class);
       groupSubService = new GroupSubServiceImpl(groupSubRepository, telegramUserService);

       newUser = new TelegramUser();
       newUser.setActive(true);
       newUser.setChatId(CHAT_ID);

       Mockito.when(telegramUserService.findByChatId(CHAT_ID)).thenReturn(Optional.of(newUser));
   }

   @Test
   public void shouldProperlySaveGroup() {
       //given

       GroupDiscussionInfo groupDiscussionInfo = new GroupDiscussionInfo();
       groupDiscussionInfo.setId(1);
       groupDiscussionInfo.setTitle("g1");

       GroupSub expectedGroupSub = new GroupSub();
       expectedGroupSub.setId(groupDiscussionInfo.getId());
       expectedGroupSub.setTitle(groupDiscussionInfo.getTitle());
       expectedGroupSub.addUser(newUser);

       //when
       groupSubService.save(CHAT_ID, groupDiscussionInfo);

       //then
       Mockito.verify(groupSubRepository).save(expectedGroupSub);
   }

   @Test
   public void shouldProperlyAddUserToExistingGroup() {
       //given
       TelegramUser oldTelegramUser = new TelegramUser();
       oldTelegramUser.setChatId("2");
       oldTelegramUser.setActive(true);

       GroupDiscussionInfo groupDiscussionInfo = new GroupDiscussionInfo();
       groupDiscussionInfo.setId(1);
       groupDiscussionInfo.setTitle("g1");

       GroupSub groupFromDB = new GroupSub();
       groupFromDB.setId(groupDiscussionInfo.getId());
       groupFromDB.setTitle(groupDiscussionInfo.getTitle());
       groupFromDB.addUser(oldTelegramUser);

       Mockito.when(groupSubRepository.findById(groupDiscussionInfo.getId())).thenReturn(Optional.of(groupFromDB));

       GroupSub expectedGroupSub = new GroupSub();
       expectedGroupSub.setId(groupDiscussionInfo.getId());
       expectedGroupSub.setTitle(groupDiscussionInfo.getTitle());
       expectedGroupSub.addUser(oldTelegramUser);
       expectedGroupSub.addUser(newUser);

       //when
       groupSubService.save(CHAT_ID, groupDiscussionInfo);

       //then
       Mockito.verify(groupSubRepository).findById(groupDiscussionInfo.getId());
       Mockito.verify(groupSubRepository).save(expectedGroupSub);
   }

}
Added the init() method here with the BeforeEach annotation. In this way, they usually create a method that will be executed before starting each test, and you can put the common logic for all tests into it. In our case, you need to lock TelegramUserService in the same way for all tests of this class, so it is reasonable to transfer this logic to a common method. There are two mokito constructions used here:
  • Mockito.when(o1.m1(a1)).thenReturn(o2) - in it we say that when the m1 method with the argument a1 is called in the o1 object, the method will return the o2 object . This is almost the most important functionality of mockito - to make the mock object return exactly what we need;

  • Mockito.verify(o1).m1(a1) - which checks that method m1 was called on object o1 with argument a1 . It was possible, of course, to use the returned object of the save method, but I decided to make it a little more complicated by showing one more possible way. When can it be useful? In cases where mock class methods return void. Then it won't work without Mockito.verify)))

We continue to adhere to the idea that tests need to be written, and they need to be written a lot. The next step is working with the telegram bot team.

Create command /addGroupSub

Here we need to perform the following logic: if we just receive a command, without any context, we help the user and pass him a list of all groups with their IDs so that he can send the necessary information to the bot. And if the user sends a command to the bot with some other word (words) - find a group with that ID or write that there is no such group. Let's add a new value in our enam - CommandName:
ADD_GROUP_SUB("/addgroupsub")
Moving on from the database to the telegram bot, we create the AddGroupSubCommand class in the command package:
package com.github.codegymcommunity.jrtb.command;

import com.github.codegymcommunity.jrtb.codegymclient.CodeGymGroupClient;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupDiscussionInfo;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupRequestArgs;
import com.github.codegymcommunity.jrtb.repository.entity.GroupSub;
import com.github.codegymcommunity.jrtb.service.GroupSubService;
import com.github.codegymcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;

import java.util.stream.Collectors;

import static com.github.codegymcommunity.jrtb.command.CommandName.ADD_GROUP_SUB;
import static com.github.codegymcommunity.jrtb.command.CommandUtils.getChatId;
import static com.github.codegymcommunity.jrtb.command.CommandUtils.getMessage;
import static java.util.Objects.isNull;
import static org.apache.commons.lang3.StringUtils.SPACE;
import static org.apache.commons.lang3.StringUtils.isNumeric;

/**
* Add Group subscription {@link Command}.
*/
public class AddGroupSubCommand implements Command {

   private final SendBotMessageService sendBotMessageService;
   private final CodeGymGroupClient codeGymGroupClient;
   private final GroupSubService groupSubService;

   public AddGroupSubCommand(SendBotMessageService sendBotMessageService, CodeGymGroupClient codeGymGroupClient,
                             GroupSubService groupSubService) {
       this.sendBotMessageService = sendBotMessageService;
       this.codeGymGroupClient = codeGymGroupClient;
       this.groupSubService = groupSubService;
   }

   @Override
   public void execute(Update update) {
       if (getMessage(update).equalsIgnoreCase(ADD_GROUP_SUB.getCommandName())) {
           sendGroupIdList(getChatId(update));
           return;
       }
       String groupId = getMessage(update).split(SPACE)[1];
       String chatId = getChatId(update);
       if (isNumeric(groupId)) {
           GroupDiscussionInfo groupById = codeGymGroupClient.getGroupById(Integer.parseInt(groupId));
           if (isNull(groupById.getId())) {
               sendGroupNotFound(chatId, groupId);
           }
           GroupSub savedGroupSub = groupSubService.save(chatId, groupById);
           sendBotMessageService.sendMessage(chatId, "Подписал на группу " + savedGroupSub.getTitle());
       } else {
           sendGroupNotFound(chatId, groupId);
       }
   }

   private void sendGroupNotFound(String chatId, String groupId) {
       String groupNotFoundMessage = "Нет группы с ID = \"%s\"";
       sendBotMessageService.sendMessage(chatId, String.format(groupNotFoundMessage, groupId));
   }

   private void sendGroupIdList(String chatId) {
       String groupIds = codeGymGroupClient.getGroupList(GroupRequestArgs.builder().build()).stream()
               .map(group -> String.format("%s - %s \n", group.getTitle(), group.getId()))
               .collect(Collectors.joining());

       String message = "Whatбы подписаться на группу - передай комадну вместе с ID группы. \n" +
               "Например: /addGroupSub 16. \n\n" +
               "я подготовил список всех групп - выберай Howую хочешь :) \n\n" +
               "Name группы - ID группы \n\n" +
               "%s";

       sendBotMessageService.sendMessage(chatId, String.format(message, groupIds));
   }
}
This class uses the isNumeric method from the apache-commons library, so let's add it to our memory:
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
  <version>${apache.commons.version}</version>
</dependency>
And in the properties block:
<apache.commons.version>3.11</apache.commons.version>
All this logic is in the class. Read it carefully. There will be questions / suggestions - write in the comments. After that, we need to add the command in the CommandContainer to our command map:
.put(ADD_GROUP_SUB.getCommandName(), new AddGroupSubCommand(sendBotMessageService, codeGymGroupClient, groupSubService))
And all for this team. I would like to somehow check this functionality, but so far you can really see it only in the database. In the third part, I will add changes from JRTB-6 so that we can see the list of groups that the user is following. Now it would be nice to check it all. To do this, perform all the actions in Telegram and check in the database. Since we have written tests, everything should be fine. The article has already come out quite a bit, so we will write a test for AddGroupSubCommand later, and add TODO in the code so as not to forget.

conclusions

In this article, we looked at the work of adding functionality across the entire application, from the database to working with the client who uses the bot. Usually such tasks help to understand the project, to understand its essence. Understand how it works. Now the topics are not easy, so do not be shy: write your questions in the comments, and I will try to answer them. Like the project? Give it a star on github : this way it will be clear that they are interested in the project, and I will be happy. As they say, the master is always pleased when his work is appreciated. The code will contain all three parts of STEP_6 and will be available before this article. How to find out about it? Easy - join a telegram channel, where I publish all the information about my articles about the telegram bot. Thanks for reading! Part 3 is already here ."Java project from A to Z": Adding the ability to subscribe to a group of articles.  Part 2 - 2

List of all materials in the series at the beginning of this article.

Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION