Olá a todos! Continuamos trabalhando na tarefa que iniciamos na semana passada .
Implementamos JRTB-5
Agora precisamos adicionar um comando para que possamos assinar algum grupo de artigos do JavaRush. Como fazer isso? Seguiremos o cenário mais simples que criei. Como temos acesso por ID de grupo, precisamos que o usuário o transfira. Para isso, o usuário digitará o comando /addGroupSub GROUP_ID, que funcionará de duas maneiras: se vier apenas o comando em si: /addGroupSub , uma lista de todos os grupos e seus IDs será enviada em resposta. Então o usuário poderá selecionar o ID do grupo que necessita e criar a segunda versão da solicitação neste comando: /addGroupSub GROUP_ID - e então haverá um registro deste grupo com este usuário. Acho que podemos fazer melhor no futuro. Nosso objetivo é mostrar o desenvolvimento, e não a experiência superlegal do usuário (tenho vergonha de dizer, mas não conheço o termo em russo que significaria isso). Para adicionar adequadamente a funcionalidade que percorre todo o aplicativo (no nosso caso, do cliente do bot de telegrama ao banco de dados), você precisa começar de algum ponto. Faremos isso do lado do banco de dados.Adicionando uma nova migração ao banco de dados
A primeira coisa a fazer é adicionar uma nova migração de banco de dados e a capacidade de salvar dados de assinatura de grupos de usuários em JR. Para lembrar como deveria ser, volte ao artigo “ Planejamento de projetos: medir sete vezes ”. Lá na segunda foto há um diagrama aproximado do banco de dados. Precisamos adicionar uma tabela para armazenar informações do grupo:- O ID do grupo no JavaRush também será o nosso ID. Confiamos neles e acreditamos que esses IDs são únicos;
- título - nas nossas fotos era nome - o nome informal do grupo; isto é, o que vemos no site JavaRush;
- last_article_id - e este é um campo interessante. Ele armazenará o último ID do artigo deste grupo, que o bot já enviou aos seus assinantes. Utilizando este campo, o mecanismo de busca de novos artigos funcionará. Novos assinantes não receberão artigos publicados antes da inscrição do usuário: apenas aqueles que foram publicados após a inscrição no grupo.
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)
);
É importante observar que primeiro altero a tabela antiga - adiciono uma chave primária a ela. De alguma forma, senti falta disso na época, mas agora o MySQL não me deu a oportunidade de adicionar uma FOREIGN KEY para a tabela gorup_x_user e, como parte dessa migração, atualizei o banco de dados. Observe um aspecto importante. A alteração do banco de dados deve ser feita exatamente desta forma - tudo o que é necessário está na nova migração, mas não atualizando uma migração já lançada. Sim, no nosso caso nada aconteceria, pois este é um projeto de teste e sabemos que está implantado em apenas um local, mas esta seria a abordagem errada. Mas queremos que tudo dê certo. Em seguida, vem a exclusão de tabelas antes de criá-las. Por que é isso? Para que se por acaso existissem tabelas com tais nomes no banco de dados, a migração não falharia e funcionaria exatamente como esperado. E então adicionamos duas tabelas. Tudo estava como queríamos. Agora precisamos lançar nosso aplicativo. Se tudo começar e não falhar, a migração será registrada. E para verificar isso, vamos ao banco de dados para ter certeza de que: a) tais tabelas apareceram; b) há uma nova entrada na tabela técnica de flyway. Isso conclui o trabalho de migração, vamos passar para os repositórios.
Adicionando uma camada de repositório
Graças ao Spring Boot Data, tudo é muito simples aqui: precisamos adicionar a entidade GroupSub, atualizar ligeiramente o TelegramUser e adicionar um GroupSubRepository quase vazio: Adicionamos a entidade GroupSub ao mesmo pacote que o 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);
}
}
Uma coisa que vale a pena notar é que temos um campo de usuários adicional que conterá uma coleção de todos os usuários inscritos no grupo. E duas anotações - ManyToMany e JoinTable - são exatamente o que precisamos para isso. O mesmo campo precisa ser adicionado para TelegramUser:
@ManyToMany(mappedBy = "users", fetch = FetchType.EAGER)
private List<GroupSub> groupSubs;
Este campo usa junções escritas na entidade GroupSub. E, de fato, nossa classe de repositório para GroupSub é 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> {
}
Neste estágio, não precisamos de métodos adicionais: aqueles implementados no ancestral JpaRepository são suficientes para nós. Vamos escrever um teste no TelegramUserRepositoryIT que verificará se nosso muitos para muitos funciona. A ideia do teste é adicionar 5 grupos de assinaturas por usuário ao banco de dados através de um script sql, pegar esse usuário pelo seu ID e verificar se recebemos exatamente esses grupos e com exatamente os mesmos valores. Como fazer isso? Você pode incorporar um contador aos dados, que podemos analisar e verificar. Aqui está o script fiveGroupSubsForUser.sql:
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);
E o teste em si:
@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());
}
}
Agora vamos adicionar um teste com o mesmo significado para a entidade GroupSub. Para fazer isso, vamos criar uma classe de teste groupSubRepositoryIT no mesmo pacote que 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());
}
}
}
E o script fiveUsersForGroupSub.sql ausente:
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);
Neste ponto, parte do trabalho com o repositório pode ser considerada concluída. Agora vamos escrever uma camada de serviço.
Nós escrevemos GroupSubService
Nesta fase, para trabalhar com grupos de assinaturas, só precisamos conseguir salvá-los, então não há problema: criamos o serviço GroupSubService e sua implementação do GroupSubServiceImpl em um pacote que contém outros serviços - serviço: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);
}
E sua implementação:
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);
}
}
Para que o Spring Data funcione corretamente e um registro muitos para muitos seja criado, precisamos obter o usuário do nosso banco de dados para o grupo de assinaturas que estamos criando e adicioná-lo ao objeto GroupSub. Assim, ao transferirmos esta assinatura para salvar, também será criada uma conexão através da tabela group_x_user. Pode haver uma situação em que esse grupo de assinaturas já tenha sido criado e você só precise adicionar outro usuário a ele. Para isso, primeiro obtemos o ID do grupo do banco de dados, e se houver registro trabalhamos com ele, caso contrário criamos um novo. É importante ressaltar que para trabalhar com TelegramUser utilizamos TelegramUserService para seguir o último dos princípios SOLID. No momento, se não encontrarmos um registro por ID, simplesmente lanço uma exceção. Não está sendo processado de forma alguma agora: faremos isso bem no final, antes do MVP. Vamos escrever dois testes de unidade para a classe GroupSubServiceTest . De quais precisamos? Quero ter certeza de que o método save será chamado no GroupSubRepository e uma entidade com um único usuário será passada para o GroupSub - aquele que retornará o TelegramUserService para nós usando o ID fornecido. E a segunda opção, quando um grupo com o mesmo ID já está no banco de dados e este grupo já possui um usuário, e você precisa verificar se outro usuário será adicionado a este grupo e este objeto será salvo. Aqui está a implementação:
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);
}
}
Também adicionei o método init() com a anotação BeforeEach. Dessa forma, você geralmente cria um método que será executado antes de cada teste ser executado e pode colocar nele uma lógica comum para todos os testes. No nosso caso, precisamos bloquear o TelegramUserService da mesma forma para todos os testes desta classe, por isso faz sentido transferir esta lógica para um método comum. Existem dois designs de mokito usados aqui:
-
Mockito.when(o1.m1(a1)).thenReturn(o2) - nele dizemos que quando o método m1 é chamado no objeto o1 com o argumento a1 , o método retornará o objeto o2 . Esta é quase a funcionalidade mais importante do mockito - forçar o objeto simulado a retornar exatamente o que precisamos;
-
Mockito.verify(o1).m1(a1) - que verifica se o método m1 foi chamado no objeto o1 com o argumento a1 . Era possível, claro, usar o objeto retornado do método save, mas resolvi complicar um pouco mais mostrando outro método possível. Quando pode ser útil? Nos casos em que métodos de classes simuladas retornam nulos. Então sem Mockito.verify não haverá trabalho)))
Crie o comando /addGroupSub
Aqui precisamos realizar a seguinte lógica: se recebermos apenas um comando, sem nenhum contexto, ajudamos o usuário e entregamos a ele uma lista de todos os grupos com seus IDs para que ele passe as informações necessárias ao bot. E se o usuário enviar um comando ao bot com alguma outra(s) palavra(s), encontre um grupo com esse ID ou escreva que tal grupo não existe. Vamos adicionar um novo valor em nosso ename - CommandName:ADD_GROUP_SUB("/addgroupsub")
Vamos passar do banco de dados para o bot de telegrama - crie a classe AddGroupSubCommand no pacote de comando:
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));
}
}
Esta classe usa o método isNumeric da biblioteca apache-commons, então vamos adicioná-lo à nossa memória:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${apache.commons.version}</version>
</dependency>
E no bloco de propriedades:
<apache.commons.version>3.11</apache.commons.version>
Toda essa lógica está na classe. Leia atentamente. Se você tiver alguma dúvida/sugestão, escreva nos comentários. Depois disso, precisamos adicionar o comando ao CommandContainer em nosso mapa de comandos:
.put(ADD_GROUP_SUB.getCommandName(), new AddGroupSubCommand(sendBotMessageService, javaRushGroupClient, groupSubService))
E tudo para esta equipe. Eu gostaria de testar de alguma forma essa funcionalidade, mas até agora só consigo ver isso no banco de dados. Na terceira parte, adicionarei alterações do JRTB-6 para que possamos ver a lista de grupos nos quais um usuário está inscrito. Agora seria bom verificar tudo isso. Para isso, realizaremos todas as ações no Telegram e verificaremos no banco de dados. Como escrevemos testes, tudo deve ficar bem. O artigo já é bastante longo, então escreveremos um teste para AddGroupSubCommand mais tarde e adicionaremos TODO no código para não esquecer.
GO TO FULL VERSION