JavaRush /Java Blog /Random EN /We are adding the ability to subscribe to a group of arti...

We are adding 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

We implement JRTB-5

Now we need to add a command so that we can subscribe to some group of articles from JavaRush. 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 command /addGroupSub GROUP_ID, which will work in one of two ways: if only the command itself comes: /addGroupSub , a list of all groups and their IDs is sent in response. Then the user will be able to select the group ID he needs and create the second version of the request in this command: /addGroupSub GROUP_ID - and then there will be an entry for this group with this user. I think we can do better in the future. Our goal is to show the development, and not the super cool user experience (I’m ashamed to say, but I don’t know the term in Russian that would mean this). To properly add functionality that goes through the entire application (in our case, from the telegram bot client to the database), you need to start at some end. We will do this 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 in the second photo there is an approximate diagram of the database. We need to add a table to store group information:
  • The group ID in JavaRush 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 JavaRush website;
  • last_article_id - and this is an interesting field. It will store the last ID of the article in this group, which the bot has already sent to its subscribers. Using this field, the mechanism for searching for new articles will work. New subscribers will not receive articles published before the user subscribed: only those that were 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 group subscriptions (one-to-many), and each group subscription can have many users (one-to- many, only on the other side). It turns out that this will be our many-to-many. For those who have questions, review the articles in the database. Yes, I’m soon planning to create a post on the Telegram channel, where I will put together all the articles on the database. This is 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 this at the time, but now MySQL did not give me the opportunity to add a FOREIGN KEY for the gorup_x_user table, and as part of this migration I updated the database. Please note an important aspect. Changing the database should be done exactly this way - everything that is needed is in the new migration, but not by updating an already released migration. Yes, in our case nothing would happen, 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 deleting tables before creating them. Why is this? So that if by some chance there were tables with such names in the database, the migration would not fail and would work exactly as expected. And then we add two tables. Everything was as we wanted. Now we need to launch our application. If everything starts and doesn’t break, then the migration is recorded. And 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, slightly update TelegramUser and add an almost empty GroupSubRepository: We add the GroupSub entity to the same package as TelegramUser:
package com.github.javarushcommunity.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);
   }
}
One thing worth noting is that we have an additional users field that will contain a collection of all users subscribed to the group. And two annotations - ManyToMany and JoinTable - are exactly what we need for this. The same field needs to be added for TelegramUser:
@ManyToMany(mappedBy = "users", fetch = FetchType.EAGER)
private List<GroupSub> groupSubs;
This field uses joins written in the GroupSub entity. And, in fact, our repository class for GroupSub is GroupSubRepository :
package com.github.javarushcommunity.jrtb.repository;

import com.github.javarushcommunity.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 implemented in the JpaRepository ancestor 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 per user to the database through an sql script, get this user by his ID and check that we received exactly those groups and with exactly the same values. How to do it? You can embed a counter into the data, which we can 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.javarushcommunity.jrtb.repository;

import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.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);
At this point, part of the work with the repository can be considered completed. Now let's write a service layer.

We write GroupSubService

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

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.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.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.GroupSubRepository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.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 a many-to-many record to be created, we need to get the user from our database for the subscription group 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 another 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 to work with TelegramUser we use TelegramUserService to follow the last of the SOLID principles. At the moment, if we don't find a record by ID, I just throw an exception. It is not being processed in any way now: we will do this at the very end, before the MVP. Let's write two unit tests for the GroupSubServiceTest class . Which ones 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 GroupSub - the one that will return TelegramUserService to us using the provided ID. And the second option, when a group with the same ID is already 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's the implementation:
package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.repository.GroupSubRepository;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.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);
   }

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

  • Mockito.verify(o1).m1(a1) - which verifies 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 another possible method. When can it be useful? In cases where methods of mock classes return void. Then without Mockito.verify there will be no work)))

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

Create the command /addGroupSub

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

import com.github.javarushcommunity.jrtb.javarushclient.JavaRushGroupClient;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupRequestArgs;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.service.GroupSubService;
import com.github.javarushcommunity.jrtb.service.SendBotMessageService;
import org.telegram.telegrambots.meta.api.objects.Update;

import java.util.stream.Collectors;

import static com.github.javarushcommunity.jrtb.command.CommandName.ADD_GROUP_SUB;
import static com.github.javarushcommunity.jrtb.command.CommandUtils.getChatId;
import static com.github.javarushcommunity.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 JavaRushGroupClient javaRushGroupClient;
   private final GroupSubService groupSubService;

   public AddGroupSubCommand(SendBotMessageService sendBotMessageService, JavaRushGroupClient javaRushGroupClient,
                             GroupSubService groupSubService) {
       this.sendBotMessageService = sendBotMessageService;
       this.javaRushGroupClient = javaRushGroupClient;
       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 = javaRushGroupClient.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 = javaRushGroupClient.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. If you have any questions/suggestions, write them in the comments. After this, we need to add the command to CommandContainer in our command map:
.put(ADD_GROUP_SUB.getCommandName(), new AddGroupSubCommand(sendBotMessageService, javaRushGroupClient, groupSubService))
And everything for this team. I would like to somehow test this functionality, but so far I can only really look at it in the database. In part three, I'll add changes from JRTB-6 so we can view the list of groups a user is subscribed to. Now it would be good to check all this. To do this, we will perform all the actions in Telegram and check in the database. Since we have written tests, everything should be fine. The article is already quite long, 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 through the entire application, starting from the database and ending with working with the client who uses the bot. Usually such tasks help to understand the project and understand its essence. Understand how it works. These days the topics are not easy, so don’t be shy: write your questions in the comments, and I will try to answer them. Do you 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, a 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? It’s easy - join the telegram channel , where I publish all the information about my articles about the telegram bot. Thanks for reading! Part 3 is already here .

A list of all materials in the series is at the beginning of this article.

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