Compare commits

...

3 Commits

19 changed files with 724 additions and 160 deletions

View File

@@ -0,0 +1,79 @@
# Sweater — клон популярной социальной сети
Учебный проект, реализующий основные функции социальной сети: регистрация, авторизация, публикация сообщений, подписки, профиль пользователя и др. Разработан с использованием **Spring Boot**, **Spring Security**, **MySQL**, **Thymeleaf/Freemarker**, **Flyway**, **Testcontainers** и других технологий.
---
## Функциональность
* **Сообщения**
* Добавление, просмотр, фильтрация
* Редактирование
* Пагинация с сокращённым списком ссылок
* **Пользователи**
* Регистрация с активацией по email
* Аутентификация (Spring Security)
* Авторизация по ролям (`USER`, `ADMIN`)
* Подписка/отписка от пользователей
* Просмотр списка подписчиков/подписок
* Редактирование профиля
* Хранение сессий в БД (`rememberMe`)
* **Безопасность**
* Spring Security + CustomUserDetailsService
* Шифрование паролей
* Валидация данных
* reCAPTCHA при регистрации
* Защита маршрутов по ролям (@EnableMethodSecurity)
* **Файлы и внешний вид**
* Загрузка изображений и других файлов
* Доступ к статическим ресурсам
* Bootstrap для стилизации форм и страниц
* Freemarker вместо Mustache
* **Инфраструктура и миграции**
* MySQL вместо PostgreSQL
* Flyway — миграции базы данных
* Перевод генерации ID на `IDENTITY`
* **Тестирование**
* Интеграционные тесты c Testcontainers
* Модульные тесты пользовательского сервиса
---
## Стек технологий
* **Backend**: Spring Boot, Spring MVC, Spring Data JPA, Spring Security
* **Frontend**: Freemarker, Bootstrap
* **Database**: MySQL
* **Migration**: Flyway
* **Тесты**: JUnit 5, Testcontainers
* **Прочее**: reCAPTCHA, email-activation, file upload
---
## Источники и вдохновение
- Проект реализован основываясь на видеокурсе Андрея Letscode, за что ему большое спасибо!
---
## Как запустить
1. Установите и настройте **MySQL**
2. Укажите параметры подключения в `application.properties`
3. Запустите проект:
```bash
./mvnw spring-boot:run
```
4. Откройте в браузере: `http://localhost:8080`

View File

@@ -2,9 +2,13 @@ package net.sytes.kashey.sweater.controller;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import net.sytes.kashey.sweater.domain.Message; import net.sytes.kashey.sweater.domain.Message;
import net.sytes.kashey.sweater.domain.User;
import net.sytes.kashey.sweater.domain.dto.MessageDto;
import net.sytes.kashey.sweater.repository.MessageRepository; import net.sytes.kashey.sweater.repository.MessageRepository;
import net.sytes.kashey.sweater.security.CustomUserDetails; import net.sytes.kashey.sweater.security.CustomUserDetails;
import net.sytes.kashey.sweater.service.MessageService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault; import org.springframework.data.web.PageableDefault;
@@ -24,12 +28,15 @@ import java.util.Map;
public class MainController { public class MainController {
private final MessageRepository messageRepository; private final MessageRepository messageRepository;
private final MessageService messageService;
@Value("${upload.path}") @Value("${upload.path}")
private String uploadPath; private String uploadPath;
public MainController(MessageRepository messageRepository) { public MainController(MessageRepository messageRepository, MessageService messageService) {
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.messageService = messageService;
} }
@GetMapping("/") @GetMapping("/")
@@ -40,13 +47,11 @@ public class MainController {
@GetMapping("/main") @GetMapping("/main")
public String findAllMessages(@RequestParam(required = false, defaultValue = "") String tag, public String findAllMessages(@RequestParam(required = false, defaultValue = "") String tag,
Model model, Model model,
@PageableDefault(sort = {"id"}, direction = Sort.Direction.DESC) Pageable pageable) { @PageableDefault(sort = {"id"}, direction = Sort.Direction.DESC) Pageable pageable,
@AuthenticationPrincipal CustomUserDetails customUserDetails) {
if (tag == null || tag.isEmpty()) { Page<MessageDto> page = messageService.getFilteredMessagePage(tag, pageable, customUserDetails);
model.addAttribute("page", messageRepository.findAll(pageable)); model.addAttribute("page", page);
} else {
model.addAttribute("page", messageRepository.findByTag(tag, pageable));
}
model.addAttribute("tag", tag); model.addAttribute("tag", tag);
model.addAttribute("url", "/main"); model.addAttribute("url", "/main");
return "main"; return "main";
@@ -61,7 +66,8 @@ public class MainController {
@RequestParam("file") MultipartFile file @RequestParam("file") MultipartFile file
) throws IOException { ) throws IOException {
message.setAuthor(customUserDetails.getUser()); User user = customUserDetails.getUser();
message.setAuthor(user);
if (bindingResult.hasErrors()) { if (bindingResult.hasErrors()) {
Map<String, String> errorsMap = ControllerUtils.getErrors(bindingResult); Map<String, String> errorsMap = ControllerUtils.getErrors(bindingResult);
@@ -72,7 +78,7 @@ public class MainController {
messageRepository.save(message); messageRepository.save(message);
model.addAttribute("message", null); model.addAttribute("message", null);
} }
model.addAttribute("page", messageRepository.findAll(pageable)); model.addAttribute("page", messageRepository.findAll(pageable, user));
model.addAttribute("url", "/main"); model.addAttribute("url", "/main");
model.addAttribute("tag", ""); model.addAttribute("tag", "");
return "main"; return "main";

View File

@@ -0,0 +1,115 @@
package net.sytes.kashey.sweater.controller;
import jakarta.transaction.Transactional;
import net.sytes.kashey.sweater.domain.Message;
import net.sytes.kashey.sweater.domain.User;
import net.sytes.kashey.sweater.domain.dto.MessageDto;
import net.sytes.kashey.sweater.repository.MessageRepository;
import net.sytes.kashey.sweater.security.CustomUserDetails;
import net.sytes.kashey.sweater.service.MessageService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.util.Set;
@Controller
@RequestMapping("/")
public class MessageController {
private final MessageRepository messageRepository;
private final MessageService messageService;
@Value("${upload.path}")
private String uploadPath;
public MessageController(MessageRepository messageRepository, MessageService messageService) {
this.messageRepository = messageRepository;
this.messageService = messageService;
}
@GetMapping("/user-messages/{author}")
public String getUserMessages(
@PathVariable User author,
@RequestParam(required = false) Message message,
@AuthenticationPrincipal CustomUserDetails customUserDetails,
@PageableDefault(sort = {"id"}, direction = Sort.Direction.DESC) Pageable pageable,
Model model) {
User currentUser = customUserDetails.getUser();
model.addAttribute("isCurrentUser", author.equals(currentUser));
model.addAttribute("message", message);
model.addAttribute("userChannel", author);
model.addAttribute("subscribersCount", author.getSubscribers().size());
model.addAttribute("subscriptionsCount", author.getSubscriptions().size());
model.addAttribute("isSubscriber", author.getSubscribers().contains(currentUser));
model.addAttribute("url", "/user-messages/" + author.getId());
Page<MessageDto> page = messageService.getMessagesByUserPage(currentUser, pageable, author);
model.addAttribute("page", page);
return "user_messages";
}
@PostMapping("/user-messages/{userId}")
public String getUserMessage(@AuthenticationPrincipal CustomUserDetails customUserDetails,
@PathVariable(name = "userId") Long userId,
@ModelAttribute Message message,
@RequestParam String text,
@RequestParam String tag,
@RequestParam("file") MultipartFile file
) throws IOException {
message.setAuthor(customUserDetails.getUser());
if (!ObjectUtils.isEmpty(text)) {
message.setText(text);
}
if (!ObjectUtils.isEmpty(tag)) {
message.setTag(tag);
}
ControllerUtils.saveFile(message, file, uploadPath);
messageRepository.save(message);
return "redirect:/user-messages/" + userId;
}
@GetMapping("/messages/{message}/like")
@Transactional
public String like(@AuthenticationPrincipal CustomUserDetails customUserDetails,
@PathVariable Message message,
RedirectAttributes redirectAttributes,
@RequestHeader(required = false) String referer) {
User currentUser = customUserDetails.getUser();
Set<User> likes = message.getLikes();
if (likes.contains(currentUser)) {
likes.remove(currentUser);
} else {
likes.add(currentUser);
}
UriComponents components = UriComponentsBuilder.fromHttpUrl(referer).build();
components.getQueryParams()
.entrySet()
.forEach(pair -> redirectAttributes.addAttribute(pair.getKey(), pair.getValue()));
return "redirect:" + components.getPath();
}
}

View File

@@ -79,7 +79,7 @@ public class UserController {
if ("subscribers".equals(type)) { if ("subscribers".equals(type)) {
model.addAttribute("users", user.getSubscribers()); model.addAttribute("users", user.getSubscribers());
} else if ("subscriptions".equals(type)) { } else if ("subscriptions".equals(type)) {
model.addAttribute("users", user.getSubscribtions()); model.addAttribute("users", user.getSubscriptions());
} }
return "subscriptions"; return "subscriptions";

View File

@@ -1,73 +0,0 @@
package net.sytes.kashey.sweater.controller;
import net.sytes.kashey.sweater.domain.Message;
import net.sytes.kashey.sweater.domain.User;
import net.sytes.kashey.sweater.repository.MessageRepository;
import net.sytes.kashey.sweater.security.CustomUserDetails;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@Controller
@RequestMapping("/")
public class UserMessagesController {
private final MessageRepository messageRepository;
@Value("${upload.path}")
private String uploadPath;
public UserMessagesController(MessageRepository messageRepository) {
this.messageRepository = messageRepository;
}
@GetMapping("/user-messages/{user}")
public String getUserMessages(@PathVariable User user,
@RequestParam(required = false) Message message,
@AuthenticationPrincipal CustomUserDetails customUserDetails,
Model model) {
User currentUser = customUserDetails.getUser();
model.addAttribute("isCurrentUser", user.equals(currentUser));
model.addAttribute("messages", user.getMessages());
model.addAttribute("message", message);
model.addAttribute("userChannel", user);
model.addAttribute("subscribersCount", user.getSubscribers().size());
model.addAttribute("subscriptionsCount", user.getSubscribtions().size());
model.addAttribute("isSubscriber", user.getSubscribers().contains(currentUser));
return "user_messages";
}
@PostMapping("/user-messages/{userId}")
public String getUserMessage(@AuthenticationPrincipal CustomUserDetails customUserDetails,
@PathVariable(name = "userId") Long userId,
@ModelAttribute Message message,
@RequestParam String text,
@RequestParam String tag,
@RequestParam("file") MultipartFile file
) throws IOException {
message.setAuthor(customUserDetails.getUser());
if (!ObjectUtils.isEmpty(text)) {
message.setText(text);
}
if (!ObjectUtils.isEmpty(tag)) {
message.setTag(tag);
}
ControllerUtils.saveFile(message, file, uploadPath);
messageRepository.save(message);
return "redirect:/user-messages/" + userId;
}
}

View File

@@ -2,8 +2,12 @@ package net.sytes.kashey.sweater.domain;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import net.sytes.kashey.sweater.domain.util.MessageHelper;
import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Length;
import java.util.HashSet;
import java.util.Set;
@Entity @Entity
public class Message { public class Message {
@Id @Id
@@ -22,6 +26,14 @@ public class Message {
@JoinColumn(name = "user_id") @JoinColumn(name = "user_id")
private User author; private User author;
@ManyToMany
@JoinTable(
name = "message_likes",
joinColumns = {@JoinColumn(name = "message_id")},
inverseJoinColumns = {@JoinColumn(name = "user_id")}
)
private Set<User> likes = new HashSet<>();
public Message() { public Message() {
} }
@@ -64,7 +76,8 @@ public class Message {
} }
public String getAuthorName() { public String getAuthorName() {
return author == null ? "&lt;none&gt;" : author.getUsername();
return MessageHelper.getAuthorName(author);
} }
public String getFilename() { public String getFilename() {
@@ -74,4 +87,12 @@ public class Message {
public void setFilename(String filename) { public void setFilename(String filename) {
this.filename = filename; this.filename = filename;
} }
public Set<User> getLikes() {
return likes;
}
public void setLikes(Set<User> likes) {
this.likes = likes;
}
} }

View File

@@ -145,11 +145,11 @@ public class User implements Serializable {
this.subscribers = subscribers; this.subscribers = subscribers;
} }
public Set<User> getSubscribtions() { public Set<User> getSubscriptions() {
return subscribtions; return subscribtions;
} }
public void setSubscribtions(Set<User> subscribtions) { public void setSubscriptions(Set<User> subscriptions) {
this.subscribtions = subscribtions; this.subscribtions = subscriptions;
} }
} }

View File

@@ -0,0 +1,68 @@
package net.sytes.kashey.sweater.domain.dto;
import net.sytes.kashey.sweater.domain.Message;
import net.sytes.kashey.sweater.domain.User;
import net.sytes.kashey.sweater.domain.util.MessageHelper;
public class MessageDto {
private final Long id;
private final String text;
private final String tag;
private final User author;
private final String filename;
private final Long likes;
private final Boolean meLiked;
public MessageDto(Message message, Long likes, Boolean meLiked) {
this.id = message.getId();
this.text = message.getText();
this.tag = message.getTag();
this.author = message.getAuthor();
this.filename = message.getFilename();
this.likes = likes;
this.meLiked = meLiked;
}
public String getAuthorName() {
return MessageHelper.getAuthorName(author);
}
public Long getId() {
return id;
}
public String getText() {
return text;
}
public String getTag() {
return tag;
}
public User getAuthor() {
return author;
}
public String getFilename() {
return filename;
}
public Long getLikes() {
return likes;
}
public Boolean getMeLiked() {
return meLiked;
}
@Override
public String toString() {
return "MessageDto{" +
"id=" + id +
", author=" + author +
", likes=" + likes +
", meLiked=" + meLiked +
'}';
}
}

View File

@@ -0,0 +1,9 @@
package net.sytes.kashey.sweater.domain.util;
import net.sytes.kashey.sweater.domain.User;
public abstract class MessageHelper {
public static String getAuthorName(User author) {
return author == null ? "&lt;none&gt;" : author.getUsername();
}
}

View File

@@ -1,13 +1,45 @@
package net.sytes.kashey.sweater.repository; package net.sytes.kashey.sweater.repository;
import net.sytes.kashey.sweater.domain.Message; import net.sytes.kashey.sweater.domain.Message;
import net.sytes.kashey.sweater.domain.User;
import net.sytes.kashey.sweater.domain.dto.MessageDto;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
public interface MessageRepository extends CrudRepository<Message, Integer> { public interface MessageRepository extends CrudRepository<Message, Integer> {
Page<Message> findByTag(String tag, Pageable pageable); @Query("select new net.sytes.kashey.sweater.domain.dto.MessageDto(\n" +
" m, \n" +
" count(ml), \n" +
" sum(case when ml = :user then 1 else 0 end) > 0\n" +
") \n" +
"from Message as m " +
"left join m.likes as ml \n" +
"where m.tag = :tag \n" +
"group by m")
Page<MessageDto> findByTag(@Param("tag") String tag, Pageable pageable, @Param("user") User user);
Page<Message> findAll(Pageable pageable); @Query("select new net.sytes.kashey.sweater.domain.dto.MessageDto(\n" +
" m, \n" +
" count(ml), \n" +
" sum(case when ml = :user then 1 else 0 end) > 0\n" +
") \n" +
"from Message as m \n" +
"left join m.likes as ml \n" +
"group by m")
Page<MessageDto> findAll(Pageable pageable, @Param("user") User user);
@Query("select new net.sytes.kashey.sweater.domain.dto.MessageDto(\n" +
" m, \n" +
" count(ml), \n" +
" sum(case when ml = :user then 1 else 0 end) > 0\n" +
") \n" +
"from Message as m " +
"left join m.likes as ml \n" +
"where m.author = :author\n" +
"group by m")
Page<MessageDto> findByUser(@Param("author") User author, Pageable pageable, @Param("user") User user);
} }

View File

@@ -0,0 +1,33 @@
package net.sytes.kashey.sweater.service;
import net.sytes.kashey.sweater.domain.User;
import net.sytes.kashey.sweater.domain.dto.MessageDto;
import net.sytes.kashey.sweater.repository.MessageRepository;
import net.sytes.kashey.sweater.security.CustomUserDetails;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@Service
public class MessageService {
private final MessageRepository messageRepository;
public MessageService(MessageRepository messageRepository) {
this.messageRepository = messageRepository;
}
public Page<MessageDto> getMessagesByUserPage(User user, Pageable pageable, User author) {
return messageRepository.findByUser(user, pageable, author);
}
public Page<MessageDto> getFilteredMessagePage(String tag, Pageable pageable, CustomUserDetails customUserDetails) {
User user = customUserDetails.getUser();
if (tag == null || tag.isEmpty()) {
return messageRepository.findAll(pageable, user);
} else {
return messageRepository.findByTag(tag, pageable, user);
}
}
}

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS message_likes
(
message_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
PRIMARY KEY (message_id, user_id),
CONSTRAINT fk_message_likes_message FOREIGN KEY (message_id) REFERENCES message (id),
CONSTRAINT fk_message_likes_user FOREIGN KEY (user_id) REFERENCES usr (id)
) ENGINE = InnoDB;

View File

@@ -3,21 +3,32 @@
<html> <html>
<head> <head>
<title>Sweater</title> <title>Sweater</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"/> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css"
integrity="sha384-mzrmE5qonljUremFsqc01SB46JvROS7bZs3IO2EmfFsd15uHvIt+Y8vEf7N7fWAU"
crossorigin="anonymous"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/css/bootstrap.min.css"
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
crossorigin="anonymous"/>
<script src="https://www.google.com/recaptcha/api.js" async="async" defer="defer"></script> <script src="https://www.google.com/recaptcha/api.js" async="async" defer="defer"></script>
</head> </head>
<body> <body>
<#include "navbar.ftl"> <#include "navbar.ftl">
<div class="container mt-5"> <div class="container mt-5">
<#nested> <#nested>
</div> </div>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.3/dist/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script> integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.3/dist/umd/popper.min.js"
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.1.3/dist/js/bootstrap.min.js"
integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
crossorigin="anonymous"></script>
</body> </body>
</html> </html>

View File

@@ -2,28 +2,38 @@
<#import "pager.ftl" as p> <#import "pager.ftl" as p>
<@p.pager url page! /> <@p.pager url page! />
<div class="row row-cols-1 row-cols-md-3 g-4" id="message-list"> <div class="card-columns" id="message-list">
<#list page.content as message> <#list page.content as message>
<div class="col"> <div class="card my-3" data-id="${message.id}">
<div class="card h-100" data-id="${message.id}"> <!-- h-100 делает карточки одинаковой высоты --> <#if message.filename??>
<#if message.filename??> <img src="/img/${message.filename}" class="card-img-top"/>
<img src="/img/${message.filename}" class="card-img-top"> </#if>
</#if> <div class="m-2">
<div class="card-body"> <span>${message.text}</span><br/>
<span>${message.text}</span><br/> <i>#${message.tag}</i>
<i>#${message.tag}</i> </div>
</div> <div class="card-footer text-muted container">
<div class="card-footer d-flex justify-content-between"> <div class="row">
<a href="/user-messages/${message.author.id}"> ${message.authorName} </a> <a class="col align-self-center"
<#if message.author.id == currentUserId> href="/user-messages/${message.author.id}">${message.authorName}</a>
<a class="btn btn-primary btn-sm" <a class="col align-self-center" href="/messages/${message.id}/like">
href="/user-messages/${message.author.id}?message=${message.id}">Edit</a> <#if message.meLiked>
<i class="fas fa-heart"></i>
<#else>
<i class="far fa-heart"></i>
</#if> </#if>
</div> ${message.likes}
</a>
<#if message.author.id == currentUserId>
<a class="col btn btn-primary" href="/user-messages/${message.author.id}?message=${message.id}">
Edit
</a>
</#if>
</div> </div>
</div> </div>
<#else> </div>
<p>No messages found</p> <#else>
</#list> No message
</div> </#list>
</div>
<@p.pager url page /> <@p.pager url page />

View File

@@ -14,43 +14,47 @@
<#else> <#else>
<#assign body = 1..page.getTotalPages()> <#assign body = 1..page.getTotalPages()>
</#if> </#if>
<div class="mt-3"> <div class="container mt-3">
<ul class="pagination"> <div class="row" >
<li class="page-item disabled"> <ul class="pagination col justify-content-center">
<a class="page-link" href="#" tabindex="-1">Pages</a> <li class="page-item disabled">
</li> <a class="page-link" href="#" tabindex="-1">Pages</a>
<#list body as p> </li>
<#if (p - 1) == page.getNumber()> <#list body as p>
<li class="page-item active"> <#if (p - 1) == page.getNumber()>
<a class="page-link" href="#" tabindex="-1">${p}</a> <li class="page-item active">
</li> <a class="page-link" href="#" tabindex="-1">${p}</a>
<#elseif p == -1> </li>
<li class="page-item disabled"> <#elseif p == -1>
<a class="page-link" href="#" tabindex="-1">...</a> <li class="page-item disabled">
</li> <a class="page-link" href="#" tabindex="-1">...</a>
<#else> </li>
<li class="page-item"> <#else>
<a class="page-link" href="${url}?page=${p - 1}&size=${page.getSize()}" tabindex="-1">${p}</a> <li class="page-item">
</li> <a class="page-link" href="${url}?page=${p - 1}&amp;size=${page.getSize()}"
</#if> tabindex="-1">${p}</a>
</#list> </li>
</ul> </#if>
</#list>
</ul>
<ul class="pagination"> <ul class="pagination col justify-content-center" >
<li class="page-item disabled"> <li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Items per page</a> <a class="page-link" href="#" tabindex="-1">Items per page</a>
</li> </li>
<#list [5, 10, 25, 50] as c> <#list [5, 10, 25, 50] as c>
<#if c == page.getSize()> <#if c == page.getSize()>
<li class="page-item active"> <li class="page-item active">
<a class="page-link" href="#" tabindex="-1">${c}</a> <a class="page-link" href="#" tabindex="-1">${c}</a>
</li> </li>
<#else> <#else>
<li class="page-item"> <li class="page-item">
<a class="page-link" href="${url}?page=${page.getNumber()}&size=${c}" tabindex="-1">${c}</a> <a class="page-link" href="${url}?page=${page.getNumber()}&amp;size=${c}"
</li> tabindex="-1">${c}</a>
</#if> </li>
</#list> </#if>
</ul> </#list>
</ul>
</div>
</div> </div>
</#macro> </#macro>

View File

@@ -1,4 +1,4 @@
package net.sytes.kashey.sweater; package net.sytes.kashey.sweater.controller;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;

View File

@@ -1,4 +1,4 @@
package net.sytes.kashey.sweater; package net.sytes.kashey.sweater.controller;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -79,8 +79,8 @@ public class MainControllerTest {
.andDo(print()) .andDo(print())
.andExpect(authenticated()) .andExpect(authenticated())
.andExpect(xpath("//*[@id='message-list']/div").nodeCount(2)) .andExpect(xpath("//*[@id='message-list']/div").nodeCount(2))
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='1']").exists()) .andExpect(xpath("/html/body/div/div[4]/div[@data-id='1']").exists())
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='3']").exists()); .andExpect(xpath("/html/body/div/div[4]/div[@data-id='3']").exists());
} }
@Test @Test
@@ -95,8 +95,8 @@ public class MainControllerTest {
.andDo(print()) .andDo(print())
.andExpect(authenticated()) .andExpect(authenticated())
.andExpect(xpath("//*[@id='message-list']/div").nodeCount(5)) .andExpect(xpath("//*[@id='message-list']/div").nodeCount(5))
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='5']").exists()) .andExpect(xpath("/html/body/div/div[4]/div[@data-id='5']").exists())
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='5']/div/span").string("fifth")) .andExpect(xpath("/html/body/div/div[4]/div[@data-id='5']/div/span").string("fifth"))
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='5']/div/i").string("#new one")); .andExpect(xpath("/html/body/div/div[4]/div[@data-id='5']/div/i").string("#new one"));
} }
} }

View File

@@ -0,0 +1,230 @@
package net.sytes.kashey.sweater.service;
import net.sytes.kashey.sweater.domain.Role;
import net.sytes.kashey.sweater.domain.User;
import net.sytes.kashey.sweater.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CustomUserDetailsServiceTest {
@Mock
UserRepository userRepository;
@Mock
EmailService emailService;
@Mock
PasswordEncoder passwordEncoder;
@InjectMocks
CustomUserDetailsService customUserDetailsService;
@Test
public void shouldAddUserWhenNewUserProvided() {
User user = new User();
user.setUsername("Dummy");
user.setEmail("test@gmail.com");
String isUserAdded = customUserDetailsService.addUser(user);
assertThat(isUserAdded).isEmpty();
assertThat(user.getActivationCode()).isNotNull();
assertThat(user.getRoles()).contains(Role.USER);
verify(userRepository, times(1))
.save(user);
verify(emailService, times(1))
.send(eq(user.getEmail()), anyString(), anyString());
}
@Test
public void shouldNotAddUserWhenUsernameIsEmpty() {
User user = new User();
String isUserAdded = customUserDetailsService.addUser(user);
assertThat(isUserAdded).contains("User name must not be empty!");
verify(userRepository, times(0))
.save(any(User.class));
verify(emailService, times(0))
.send(anyString(), anyString(), anyString());
}
@Test
public void shouldNotAddUserWhenUserAlreadyExists() {
User user = new User("Dummy");
when(userRepository.findByUsername(user.getUsername()))
.thenReturn(Optional.of(user));
String isUserAdded = customUserDetailsService.addUser(user);
assertThat(isUserAdded).contains("User already exists!");
verify(userRepository, times(0))
.save(user);
verify(emailService, times(0))
.send(anyString(), anyString(), anyString());
}
@Test
void shouldActivateUserWhenValidActivationCodeProvided() {
User user = new User();
user.setActivationCode("dummy_activation_code");
when(userRepository.findByActivationCode(user.getActivationCode()))
.thenReturn(user);
boolean isUserActivated = customUserDetailsService.activateUser(user.getActivationCode());
assertThat(isUserActivated).isTrue();
assertThat(user.getActivationCode()).isNull();
verify(userRepository, times(1))
.save(user);
}
@Test
void shouldNotActivateUserWhenInvalidActivationCodeProvided() {
boolean isUserActivated = customUserDetailsService.activateUser("dummy_activation_code");
assertThat(isUserActivated).isFalse();
verify(userRepository, times(0))
.save(any(User.class));
}
@Test
public void shouldUpdateUsernameAndRolesWhenValidDataProvided() {
User user = new User();
user.setUsername("oldUser");
user.setRoles(new HashSet<>(Collections.singleton(Role.USER)));
Map<String, String> form = new HashMap<>();
form.put("ADMIN", "true");
form.put("USER", "true");
customUserDetailsService.updateUser("newUser", form, user);
assertThat(user.getUsername()).isEqualTo("newUser");
assertThat(user.getRoles()).containsExactlyInAnyOrder(Role.ADMIN, Role.USER);
verify(userRepository, times(1)).save(user);
}
@Test
public void shouldUpdateEmailAndGenerateActivationCodeWhenEmailChanged() {
User user = new User();
user.setEmail("old@gmail.com");
customUserDetailsService.updateProfile(user, null, "new@gmail.com");
assertThat(user.getEmail()).isEqualTo("new@gmail.com");
assertThat(user.getActivationCode()).isNotNull();
verify(userRepository, times(1)).save(user);
verify(emailService, times(1)).send(anyString(), anyString(), anyString());
}
@Test
public void shouldUpdatePasswordWhenProvided() {
User user = new User();
user.setPassword("oldPassword");
customUserDetailsService.updateProfile(user, "newPassword", null);
assertThat(user.getPassword()).isEqualTo("newPassword");
verify(userRepository, times(1)).save(user);
verify(emailService, times(0)).send(anyString(), anyString(), anyString());
}
@Test
public void shouldUpdateBothEmailAndPasswordWhenBothProvided() {
User user = new User();
user.setEmail("old@gmail.com");
user.setPassword("oldPassword");
customUserDetailsService.updateProfile(user, "newPassword", "new@gmail.com");
assertThat(user.getEmail()).isEqualTo("new@gmail.com");
assertThat(user.getPassword()).isEqualTo("newPassword");
assertThat(user.getActivationCode()).isNotNull();
verify(userRepository, times(1)).save(user);
verify(emailService, times(1)).send(anyString(), anyString(), anyString());
}
@Test
public void shouldReturnAllUsers() {
User user1 = new User("Dummy1");
User user2 = new User("Dummy2");
List<User> users = Arrays.asList(user1, user2);
when(userRepository.findAll()).thenReturn(users);
List<User> result = customUserDetailsService.findAll();
assertThat(result).hasSize(2);
assertThat(result).contains(user1, user2);
verify(userRepository, times(1)).findAll();
}
@Test
public void shouldReturnEmptyListWhenNoUsersFound() {
when(userRepository.findAll()).thenReturn(Collections.emptyList());
List<User> result = customUserDetailsService.findAll();
assertThat(result).isEmpty();
verify(userRepository, times(1)).findAll();
}
@Test
public void shouldSubscribeUser() {
User subscriber = new User("Subscriber");
User userChannel = new User("UserChannel");
customUserDetailsService.subscribe(subscriber, userChannel);
assertThat(userChannel.getSubscribers()).contains(subscriber);
verify(userRepository, times(1)).save(userChannel);
}
@Test
public void shouldUnsubscribeUser() {
User subscriber = new User("Subscriber");
User userChannel = new User("UserChannel");
userChannel.getSubscribers().add(subscriber);
customUserDetailsService.unsubscribe(subscriber, userChannel);
assertThat(userChannel.getSubscribers()).doesNotContain(subscriber);
verify(userRepository, times(1)).save(userChannel);
}
@Test
public void shouldNotFailUnsubscribeIfUserIsNotSubscribed() {
User subscriber = new User("Subscriber");
User userChannel = new User("UserChannel");
customUserDetailsService.unsubscribe(subscriber, userChannel);
assertThat(userChannel.getSubscribers()).isEmpty();
verify(userRepository, times(1)).save(userChannel);
}
}

View File

@@ -1,5 +1,7 @@
DROP TABLE IF EXISTS message_likes;
DROP TABLE IF EXISTS message; DROP TABLE IF EXISTS message;
CREATE TABLE IF NOT EXISTS message CREATE TABLE IF NOT EXISTS message
( (
id BIGINT AUTO_INCREMENT PRIMARY KEY, id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -26,6 +28,15 @@ CREATE TABLE IF NOT EXISTS usr
primary key (id) primary key (id)
) engine = InnoDB; ) engine = InnoDB;
CREATE TABLE IF NOT EXISTS message_likes
(
message_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
PRIMARY KEY (message_id, user_id),
CONSTRAINT fk_message_likes_message FOREIGN KEY (message_id) REFERENCES message (id),
CONSTRAINT fk_message_likes_user FOREIGN KEY (user_id) REFERENCES usr (id)
) ENGINE = InnoDB;
DELETE DELETE
FROM user_role; FROM user_role;