¡Hola a todos! Seguimos trabajando en la tarea que iniciamos la semana pasada .
Implementamos JRTB-5
Ahora necesitamos agregar un comando para poder suscribirnos a algún grupo de artículos de JavaRush. ¿Cómo hacerlo? Seguiremos el escenario más simple que se me ocurrió. Como tenemos acceso por ID de grupo, necesitamos que el usuario lo transfiera. Para hacer esto, el usuario ingresará el comando /addGroupSub GROUP_ID, que funcionará de dos maneras: si solo viene el comando en sí: /addGroupSub , se envía en respuesta una lista de todos los grupos y sus ID. Luego, el usuario podrá seleccionar el ID de grupo que necesita y crear la segunda versión de la solicitud en este comando: /addGroupSub GROUP_ID - y luego habrá una entrada para este grupo con este usuario. Creo que podemos hacerlo mejor en el futuro. Nuestro objetivo es mostrar el desarrollo, y no la experiencia de usuario genial (me avergüenza decirlo, pero no sé el término en ruso que significaría esto). Para agregar correctamente una funcionalidad que abarque toda la aplicación (en nuestro caso, desde el cliente del bot de Telegram hasta la base de datos), debe comenzar por algún extremo. Haremos esto desde el lado de la base de datos.Agregar una nueva migración a la base de datos
Lo primero que debe hacer es agregar una nueva migración de base de datos y la capacidad de guardar los datos de suscripción del grupo de usuarios en JR. Para recordar cómo debe ser, vuelve al artículo “ Planificación de proyectos: mide siete veces ”. Allí en la segunda foto hay un diagrama aproximado de la base de datos. Necesitamos agregar una tabla para almacenar información del grupo:- El ID del grupo en JavaRush también será nuestro ID. Confiamos en ellos y creemos que estas identificaciones son únicas;
- título - en nuestras fotos era nombre - el nombre informal del grupo; es decir, lo que vemos en el sitio web de JavaRush;
- last_article_id: y este es un campo interesante. Almacenará el último ID del artículo de este grupo, que el bot ya envió a sus suscriptores. Utilizando este campo funcionará el mecanismo de búsqueda de nuevos artículos. Los nuevos suscriptores no recibirán artículos publicados antes de que el usuario se suscribiera: sólo aquellos que fueron publicados después de suscribirse al 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)
);
Es importante tener en cuenta que primero cambio la tabla anterior: le agrego una clave principal. De alguna manera me perdí esto en ese momento, pero ahora MySQL no me dio la oportunidad de agregar una CLAVE EXTRANJERA para la tabla gorup_x_user y, como parte de esta migración, actualicé la base de datos. Tenga en cuenta un aspecto importante. El cambio de la base de datos se debe hacer exactamente de esta manera: todo lo que se necesita está en la nueva migración, pero no actualizando una migración ya publicada. Sí, en nuestro caso no pasaría nada, ya que este es un proyecto de prueba y sabemos que está implementado en un solo lugar, pero este sería un enfoque equivocado. Pero queremos que todo esté bien. Luego viene eliminar tablas antes de crearlas. ¿Por qué es esto? De modo que si por casualidad hubiera tablas con esos nombres en la base de datos, la migración no fallaría y funcionaría exactamente como se esperaba. Y luego agregamos dos tablas. Todo fue como queríamos. Ahora necesitamos iniciar nuestra aplicación. Si todo comienza y no se rompe, entonces se registra la migración. Y para comprobarlo, vamos a la base de datos para asegurarnos de que: a) hayan aparecido dichas tablas; b) hay una nueva entrada en la tabla técnica de corredores aéreos. Esto completa el trabajo de migración, pasemos a los repositorios.
Agregar una capa de repositorio
Gracias a Spring Boot Data, aquí todo es muy simple: necesitamos agregar la entidad GroupSub, actualizar ligeramente TelegramUser y agregar un GroupSubRepository casi vacío: Agregamos la entidad GroupSub al mismo paquete que 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);
}
}
Una cosa que vale la pena señalar es que tenemos un campo de usuarios adicional que contendrá una colección de todos los usuarios suscritos al grupo. Y dos anotaciones, ManyToMany y JoinTable, son exactamente lo que necesitamos para esto. Es necesario agregar el mismo campo para TelegramUser:
@ManyToMany(mappedBy = "users", fetch = FetchType.EAGER)
private List<GroupSub> groupSubs;
Este campo utiliza uniones escritas en la entidad GroupSub. Y, de hecho, nuestra clase de repositorio para GroupSub es 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> {
}
En esta etapa, no necesitamos métodos adicionales: los implementados en el ancestro JpaRepository son suficientes para nosotros. Escribamos una prueba en TelegramUserRepositoryIT que comprobará que nuestro sistema de muchos a muchos funciona. La idea de la prueba es que agreguemos 5 grupos de suscripciones por usuario a la base de datos mediante un script sql, obtengamos este usuario por su ID y comprobemos que recibimos exactamente esos grupos y con exactamente los mismos valores. ¿Cómo hacerlo? Puede incrustar un contador en los datos, que luego podemos revisar y verificar. Aquí está el 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);
Y la prueba en sí:
@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());
}
}
Ahora agreguemos una prueba del mismo significado para la entidad GroupSub. Para hacer esto, creemos una clase de prueba groupSubRepositoryIT en el mismo paquete 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());
}
}
}
Y el script fiveUsersForGroupSub.sql que falta:
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);
En este punto, parte del trabajo con el repositorio se puede considerar completado. Ahora escribamos una capa de servicio.
Escribimos GroupSubService
En esta etapa, para trabajar con grupos de suscripciones, solo necesitamos poder guardarlas, así que no hay problema: creamos el servicio GroupSubService y su implementación de GroupSubServiceImpl en un paquete que contiene otros servicios - servicio: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);
}
Y su implementación:
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 Spring Data funcione correctamente y se cree un registro de muchos a muchos, necesitamos obtener el usuario de nuestra base de datos para el grupo de suscripción que estamos creando y agregarlo al objeto GroupSub. Así, cuando transfiramos esta suscripción para guardarla, también se creará una conexión a través de la tabla group_x_user. Puede surgir una situación en la que dicho grupo de suscripción ya se haya creado y solo necesite agregarle otro usuario. Para ello, primero obtenemos el ID del grupo de la base de datos, y si hay un registro trabajamos con él, si no, creamos uno nuevo. Es importante tener en cuenta que para trabajar con TelegramUser utilizamos TelegramUserService para seguir el último de los principios SOLID. Por el momento, si no encontramos un registro por ID, simplemente lanzo una excepción. No se está procesando de ninguna manera ahora: lo haremos al final, antes del MVP. Escribamos dos pruebas unitarias para la clase GroupSubServiceTest . ¿Cuáles necesitamos? Quiero estar seguro de que se llamará al método de guardar en GroupSubRepository y que se pasará una entidad con un solo usuario a GroupSub, la que nos devolverá TelegramUserService utilizando la ID proporcionada. Y la segunda opción es cuando un grupo con el mismo ID ya está en la base de datos y este grupo ya tiene un usuario, y necesita verificar que se agregará otro usuario a este grupo y este objeto se guardará. Aquí está la implementación:
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);
}
}
También agregué el método init() con la anotación BeforeEach. De esta manera, generalmente crean un método que se ejecutará antes de que se ejecute cada prueba, y es posible ponerle una lógica común para todas las pruebas. En nuestro caso, necesitamos bloquear TelegramUserService de la misma manera para todas las pruebas de esta clase, por lo que tiene sentido transferir esta lógica a un método común. Aquí se utilizan dos diseños de mokito:
-
Mockito.when(o1.m1(a1)).thenReturn(o2) - en él decimos que cuando se llama al método m1 en el objeto o1 con el argumento a1 , el método devolverá el objeto o2 . Esta es casi la funcionalidad más importante de Mockito: forzar al objeto simulado a devolver exactamente lo que necesitamos;
-
Mockito.verify(o1).m1(a1) : que verifica que se llamó al método m1 en el objeto o1 con el argumento a1 . Por supuesto, era posible utilizar el objeto devuelto por el método save, pero decidí hacerlo un poco más complicado mostrando otro método posible. ¿Cuándo puede ser útil? En los casos en que los métodos de clases simuladas devuelven void. Entonces sin Mockito.verify no habrá trabajo)))
Crea el comando /addGroupSub
Aquí debemos realizar la siguiente lógica: si recibimos solo un comando, sin ningún contexto, ayudamos al usuario y le damos una lista de todos los grupos con sus ID para que pueda pasar la información necesaria al bot. Y si el usuario envía un comando al bot con alguna otra palabra, busque un grupo con esa ID o escriba que no existe dicho grupo. Agreguemos un nuevo valor en nuestro nombre: CommandName:ADD_GROUP_SUB("/addgroupsub")
Vayamos más allá de la base de datos al bot de Telegram: creemos la clase AddGroupSubCommand en el paquete de comandos:
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 = "Quéбы подписаться на группу - передай комадну вместе с ID группы. \n" +
"Например: /addGroupSub 16. \n\n" +
"я подготовил список всех групп - выберай Cómoую хочешь :) \n\n" +
"Nombre группы - ID группы \n\n" +
"%s";
sendBotMessageService.sendMessage(chatId, String.format(message, groupIds));
}
}
Esta clase usa el método isNumeric de la biblioteca apache-commons, así que agreguémoslo a nuestra memoria:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${apache.commons.version}</version>
</dependency>
Y en el bloque de propiedades:
<apache.commons.version>3.11</apache.commons.version>
Toda esta lógica está en la clase. Léalo detenidamente. Si tienes alguna pregunta/sugerencia, escríbela en los comentarios. Después de esto, necesitamos agregar el comando a CommandContainer en nuestro mapa de comandos:
.put(ADD_GROUP_SUB.getCommandName(), new AddGroupSubCommand(sendBotMessageService, javaRushGroupClient, groupSubService))
Y todo por este equipo. Me gustaría probar de alguna manera esta funcionalidad, pero hasta ahora sólo puedo verla en la base de datos. En la tercera parte, agregaré cambios desde JRTB-6 para que podamos ver la lista de grupos a los que está suscrito un usuario. Ahora sería bueno comprobar todo esto. Para ello realizaremos todas las acciones en Telegram y consultaremos en la base de datos. Como tenemos pruebas escritas, todo debería estar bien. El artículo ya es bastante largo, por lo que escribiremos una prueba para AddGroupSubCommand más adelante y agregaremos TODO en el código para no olvidarlo.
GO TO FULL VERSION