Всім привіт! Продовжуємо роботу над завданням, яке ми розпочали минулого тижня .
Реалізуємо JRTB-5
Тепер нам потрібно додати команду, щоб ми могли передплатити якусь групу статей з JavaRush. Як це зробити? Ітимемо за найпростішим сценарієм, який я придумав. Так як доступ у нас по ID групи, нам потрібно, щоб його користувач передав. Для цього користувач вводитиме команду /addGroupSub GROUP_ID, яка буде працювати за одним із двох варіантів: якщо приходить тільки сама команда: /addGroupSub , у відповідь передається список усіх груп та їх ID-шники. Тоді користувач зможе вибрати потрібний ID групи і скласти другий варіант запиту в цій команді: /addGroupSubGROUP_ID - і тоді вже буде запис цієї групи з цим користувачем. Думаю, у майбутньому можна буде зробити й краще. Наша мета — показати саме розробку, а не супер крутий user experience (соромно сказати, але я не знаю терміну російською, яка б означала це). Щоб правильно додати функціональність, яка йде крізь усі програми (у нашому випадку - від клієнта телеграм-бота до бази даних), потрібно починати з якогось кінця. Робитимемо це з боку БД.Додаємо нову міграцію до БД
Перше, що потрібно зробити, - додати нову міграцію бази даних і можливість зберігати дані про підписку користувачів на групи в JR. Щоб згадати, як це має бути, поверніться до статті “ Планування проекту: сім разів відміряй ”. Там на другому фото намальовано приблизну схему БД. Нам потрібно додати таблицю для збереження інформації групи:- ID групи у JavaRush буде і нашим ID. Ми довіряємо їм і вважаємо, що ці ID є унікальними;
- title – у наших картинках це було name – неформальна назва групи; тобто те, що бачимо на сайті JavaRush;
- last_article_id – а це цікаве поле. Воно буде зберігати останній ID-шник статті у цій групі, яку бот вже надіслав своїм передплатникам. За допомогою цього поля працюватиме механізм пошуку нових статей. Новим передплатникам не будуть надходити статті, опубліковані до того, як користувач підписався: лише ті, що вийшли після підписки на групу.
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)
);
Спочатку я змінюю стару таблицю — додаю їй первинний ключ. Я якось це змарнував того разу, але тепер мені MySQL не давав можливість додати FOREIGN KEY для таблиці gorup_x_user, і я в рамках цієї міграції оновив базу даних. Зверніть увагу на важливий аспект. Зміну БД потрібно робити саме так — у новій міграції все те, що потрібно, але ніяк не шляхом оновлення вже випущеної міграції. Так, у нашому випадку нічого не сталося б, оскільки це тестовий проект і ми знаємо, що він розгортається тільки в одному місці, але це був би неправильний підхід. Адже ми хочемо, щоб усе було правильно. Далі йде видалення таблиць перед їх створенням. Навіщо це? Щоб якби за якоюсь випадковістю таблиці з такими іменами були в БД, міграція не впала б і відпрацювала саме так, як і очікується. І далі додаємо дві таблиці. Усі як і хотіли. Тепер потрібно запустити нашу програму. Якщо все запуститься і не зламається, то міграція записана. А щоб це перевірити ще раз, йдемо в базу даних переконатися, що: а) такі таблиці з'явабося; б) є новий запис у технічній таблиці flyway. У цьому роботу з міграцією закінчено, переходимо до репозиторіям.
Додаємо шар репозиторію
Завдяки Spring Boot Data тут все дуже просто: нам потрібно додати сутність GroupSub, трохи оновити TelegramUser і додати майже порожній GroupSubRepository: GroupSub entity додаємо в той же пакет, що і 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);
}
}
З того, що варто відзначити, у нас є додаткове поле users, яке міститиме колекцію всіх користувачів, підписаних на групу. І дві інструкції - ManyToMany і JoinTable - саме для цього нам і потрібні. Таке за змістом поле потрібно додати і до TelegramUser:
@ManyToMany(mappedBy = "users", fetch = FetchType.EAGER)
private List<GroupSub> groupSubs;
Це поле використовує джоїни, написані в GroupSub сутності. І, власне, наш клас репозиторій для GroupSub — 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> {
}
На цьому етапі нам не потрібні додаткові методи: тих, що реалізовані в предку JpaRepository, нам вистачає. Напишемо тест в TelegramUserRepositoryIT, який перевірятиме, що наша багато-багато працює. Ідея тесту полягає в тому, що ми додамо до бази даних 5 груп підписок на одного користувача через sql скрипт, отримаємо цього користувача за його ID та перевіримо, що нам прийшли саме ті групи і саме з такими значеннями. Як це зробити? У дані можна зашити лічильник, яким потім пройдемо і перевіримо. Ось скрипт 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);
І сам тест:
@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());
}
}
Тепер додамо такий самий за змістом тест для GroupSub сутності. Для цього створимо тестовий клас groupSubRepositoryIT у тому ж пакеті, що і 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());
}
}
}
І відсутній скрипт fiveUsersForGroupSub.sql:
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);
У цьому частину роботи з репозиторієм вважатимуться закінченою. Тепер напишемо шар сервісів.
Пишемо GroupSubService
На даному етапі для роботи з групами передплат нам потрібно лише вміти зберігати їх, тому жодних проблем: створюємо сервіс GroupSubService та його реалізацію GroupSubServiceImpl у пакеті, де є й інші сервіси — 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);
}
І його реалізацію:
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);
}
}
Щоб Spring Data запрацювала правильно і створився запис many-to-many, нам потрібно для групи підписки, яку ми створюємо, дістати з нашого БД користувача і додати його в об'єкт GroupSub. Тим самим, коли ми передамо на збереження цю передплату, буде створено ще й зв'язок через таблицю group_x_user. Можливо ситуація, коли вже створено таку групу передплати і треба легко додати до неї ще одного користувача. Для цього ми спочатку отримуємо за ID групи з БД, і якщо запис є, працюємо з ним, якщо ні – створюємо новий. Важливо, що для роботи з TelegramUser ми використовуємо TelegramUserService, щоб дотримуватися останнього з принципів SOLID. На даний момент, якщо ми не знаходимо запису за ID, я просто викидаю виняток. Воно ніяк зараз не обробляється: ми це зробимо вже наприкінці перед MVP. Напишемо два юніт-тести на класGroupSubServiceTest . Які нам потрібні? Я хочу бути впевненим, що GroupSubRepository викликає метод save і буде передана GroupSub сутність з одним єдиним користувачем — тим, що поверне нам TelegramUserService за наданим ID. І другий варіант, коли група з таким ID вже є в БД і вже ця група має один користувач, і потрібно перевірити, що до цієї групи буде додано ще один користувач і цей об'єкт збережений. Ось реалізація:
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);
}
}
Додав тут ще метод init() з інструкцією BeforeEach. Таким чином, зазвичай створюють метод, який буде виконуватися перед запуском кожного тесту, і в нього можна винести загальну логіку для всіх тестів. У нашому випадку замокати TelegramUserService потрібно однаково для всіх тестів цього класу, тому розумно перенести цю логіку в загальний метод. Тут використовується дві конструкції з мокіто:
-
Mockito.when(o1.m1(a1)).thenReturn(o2) — в ній говоримо, що коли в об'єкті o1 буде викликаний метод m1 з аргументом a1 , метод поверне об'єкт o2 . Це чи не найголовніша функціональність мокито – змусити моковий об'єкт повертати саме те, що нам потрібно;
-
Mockito.verify(o1).m1(a1) - який перевіряє, що в об'єкті o1 був викликаний метод m1 з аргументом a1 . Можна було, звичайно, скористатися об'єктом методу save, що повертається, але я вирішив зробити трохи складніше, показавши ще один з можливих способів. Коли він може бути корисним? У випадках, коли методи мокових класів повертають void. Тоді без Mockito.verify справи не буде)))
Створюємо команду /addGroupSub
Тут потрібно виконати наступну логіку: якщо нам приходить просто команда, без будь-якого контексту, ми допомагаємо користувачеві та передаємо йому список усіх груп з їхніми ID-шниками, щоб він зміг передати потрібну інформацію. А якщо користувач передає боту команду ще з якимось словом (словами) — знайти групу з таким ID або написати, що такої групи немає. Додамо нове значення в нашому єнамі - CommandName:ADD_GROUP_SUB("/addgroupsub")
Рухаємося далі від БД до телеграм-бота – створюємо клас AddGroupSubCommand у пакеті command:
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 = "Щобы подписаться на группу - передай комадну вместе с ID группы. \n" +
"Например: /addGroupSub 16. \n\n" +
"я подготовил список всех групп - выберай якую хочешь :) \n\n" +
"ім'я группы - ID группы \n\n" +
"%s";
sendBotMessageService.sendMessage(chatId, String.format(message, groupIds));
}
}
У цьому класі використовується метод isNumeric з apache-commons бібліотеки, тому додамо її до нашого пам'ятника:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${apache.commons.version}</version>
</dependency>
І в блок properties:
<apache.commons.version>3.11</apache.commons.version>
Уся ця логіка перебуває у класі. Прочитайте її уважно. Будуть запитання/пропозиції – пишіть у коментарях. Після цього потрібно додати команду в CommandContainer до нашої карти команд:
.put(ADD_GROUP_SUB.getCommandName(), new AddGroupSubCommand(sendBotMessageService, codeGymGroupClient, groupSubService))
І все для цієї команди. Хочеться перевірити цю функціональність, але поки що реально можна подивитися тільки в БД. У третій частині я додам зміни з JRTB-6, щоб ми могли переглянути список груп, на які користувач підписав. Тепер добре б це все перевірити. Для цього виконаємо всі дії у Телеграмі та перевірити у базі даних. Так як у нас написані тести, все має бути чудово. Стаття вже й так вийшла чимала, тому тест для AddGroupSubCommand напишемо пізніше, а код додамо TODO, щоб не забути.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ