- Добавил интеграционные тесты с помощью Testcontainers

- Убрал сохранение данных сессии в БД, с ним не работало тестирования логина пользователя.
- Изменил стратегию генерации Id для сообщений с 'AUTO' на `IDENTITY`. Без этого ругалось на дублирования id при добавлении нового сообщения в тестах.

https://www.youtube.com/watch?v=Lnc3o8cCwZY&list=PLU2ftbIeotGpAYRP9Iv2KLIwK36-o_qYk&index=26
This commit is contained in:
Leonid
2025-03-02 11:06:11 +03:00
parent 6e4e358dee
commit 3530e9e28d
15 changed files with 323 additions and 62 deletions

25
pom.xml
View File

@@ -41,6 +41,26 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<version>RELEASE</version>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
@@ -81,12 +101,7 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
<plugins> <plugins>
<plugin> <plugin>

View File

@@ -2,7 +2,6 @@ package net.sytes.kashey.sweater.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -20,6 +19,7 @@ public class WebSecurityConfig {
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(8); return new BCryptPasswordEncoder(8);
} }
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
@@ -31,7 +31,7 @@ public class WebSecurityConfig {
.loginPage("/login") .loginPage("/login")
.defaultSuccessUrl("/main", true) .defaultSuccessUrl("/main", true)
.permitAll() .permitAll()
).rememberMe(Customizer.withDefaults()) )
.logout(LogoutConfigurer::permitAll); .logout(LogoutConfigurer::permitAll);
return http.build(); return http.build();

View File

@@ -7,7 +7,7 @@ import org.hibernate.validator.constraints.Length;
@Entity @Entity
public class Message { public class Message {
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@NotBlank(message = "Please fill in the field.") @NotBlank(message = "Please fill in the field.")

View File

@@ -19,6 +19,3 @@ spring.mail.protocol=smtps
mail.debug=true mail.debug=true
recaptcha.secret= recaptcha.secret=
spring.session.jdbc.serialize=java
spring.session.jdbc.initialize-schema=always
spring.session.jdbc.table-name=SPRING_SESSION

View File

@@ -1,48 +1,47 @@
create table message CREATE TABLE IF NOT EXISTS message
( (
id bigint not null, id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id bigint, user_id BIGINT,
filename varchar(255), filename VARCHAR(255),
tag varchar(255), tag VARCHAR(255),
text varchar(2048) not null, text VARCHAR(2048) NOT NULL
primary key (id) ) ENGINE = InnoDB;
) engine = InnoDB;
create table message_seq CREATE TABLE message_seq
( (
next_val bigint next_val BIGINT
) engine = InnoDB; ) ENGINE = InnoDB;
insert into message_seq INSERT INTO message_seq
values (1); VALUES (1);
create table user_role CREATE TABLE user_role
( (
user_id bigint not null, user_id BIGINT NOT NULL,
roles enum ('ADMIN','USER') roles ENUM ('ADMIN','USER')
) engine = InnoDB; ) ENGINE = InnoDB;
create table usr CREATE TABLE usr
( (
enabled bit not null, enabled BIT NOT NULL,
id bigint not null, id BIGINT NOT NULL,
activation_code varchar(255), activation_code VARCHAR(255),
email varchar(255), email VARCHAR(255),
password varchar(255) not null, password VARCHAR(255) NOT NULL,
username varchar(255) not null, username VARCHAR(255) NOT NULL,
primary key (id) PRIMARY KEY (id)
) engine = InnoDB; ) ENGINE = InnoDB;
create table usr_seq CREATE TABLE usr_seq
( (
next_val bigint next_val BIGINT
) engine = InnoDB; ) ENGINE = InnoDB;
insert into usr_seq INSERT INTO usr_seq
values (1); VALUES (1);
alter table message ALTER TABLE message
add constraint message_user_fk foreign key (user_id) references usr (id); ADD CONSTRAINT message_user_fk FOREIGN KEY (user_id) REFERENCES usr (id);
alter table user_role ALTER TABLE user_role
add constraint user_role_user_fk foreign key (user_id) references usr (id); ADD CONSTRAINT user_role_user_fk FOREIGN KEY (user_id) REFERENCES usr (id);

View File

@@ -1,9 +1,9 @@
<#import "parts/common.ftl" as com> <#import "parts/common.ftl" as com>
<@com.page> <@com.page>
<link href="/static/custom.css" rel="stylesheet"> <link href="/static/custom.css" rel="stylesheet"/>
<div class="input-group mb-3"> <div class="input-group mb-3">
<form method="get" action="/main" class="form-inline"> <form method="get" action="/main" class="form-inline">
<input type="text" name="tag" value="${tag}" placeholder="Tag to search"> <input type="text" name="tag" value="${tag}" placeholder="Tag to search"/>
<button type="submit" class="btn btn-primary ml-2">Search</button> <button type="submit" class="btn btn-primary ml-2">Search</button>
</form> </form>
</div> </div>

View File

@@ -4,9 +4,9 @@
<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://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 defer></script> <script src="https://www.google.com/recaptcha/api.js" async="async" defer="defer"></script>
</head> </head>
<body> <body>

View File

@@ -18,7 +18,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="text" class="form-control ${(tagError??)?string('is-invalid', '')}" <input type="text" class="form-control ${(tagError??)?string('is-invalid', '')}"
value="<#if message??>${message.tag}</#if>" name="tag" placeholder="Tag"> value="<#if message??>${message.tag}</#if>" name="tag" placeholder="Tag"/>
<#if tagError??> <#if tagError??>
<div class="invalid-feedback"> <div class="invalid-feedback">
${tagError} ${tagError}
@@ -27,7 +27,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="custom-file"> <div class="custom-file">
<input type="file" name="file" id="customFile"> <input type="file" name="file" id="customFile"/>
<label class="custom-file-label" for="customFile">Choose file</label> <label class="custom-file-label" for="customFile">Choose file</label>
</div> </div>
</div> </div>

View File

@@ -1,14 +1,14 @@
<#include "security.ftl"> <#include "security.ftl">
<div class="row row-cols-1 row-cols-md-3 g-4"> <div class="row row-cols-1 row-cols-md-3 g-4" id="message-list">
<#list messages as message> <#list messages as message>
<div class="col"> <div class="col">
<div class="card h-100"> <!-- h-100 делает карточки одинаковой высоты --> <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="card-body"> <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 d-flex justify-content-between"> <div class="card-footer d-flex justify-content-between">

View File

@@ -2,7 +2,7 @@
<#import "login.ftl" as l> <#import "login.ftl" as l>
<nav class="navbar navbar-expand-lg bg-body-tertiary"> <nav class="navbar navbar-expand-lg bg-body-tertiary">
<link href="/static/custom.css" rel="stylesheet"> <link href="/static/custom.css" rel="stylesheet"/>
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="/">Sweater</a> <a class="navbar-brand" href="/">Sweater</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"

View File

@@ -4,7 +4,7 @@
<form action="/users" method="post" class="needs-validation" novalidate> <form action="/users" method="post" class="needs-validation" novalidate>
<div class="form-group"> <div class="form-group">
<label for="username">Username:</label> <label for="username">Username:</label>
<input type="text" class="form-control" id="username" name="username" value="${user.username}" required> <input type="text" class="form-control" id="username" name="username" value="${user.username}" required/>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please provide a username. Please provide a username.
</div> </div>
@@ -14,15 +14,15 @@
<#list roles as role> <#list roles as role>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" name="${role}" <input class="form-check-input" type="checkbox" name="${role}"
id="${role}" ${user.roles?seq_contains(role)?string("checked", "")}> id="${role}" ${user.roles?seq_contains(role)?string("checked", "")}/>
<label class="form-check-label" for="${role}"> <label class="form-check-label" for="${role}">
${role} ${role}
</label> </label>
</div> </div>
</#list> </#list>
</div> </div>
<input type="hidden" value="${user.id}" name="userId"> <input type="hidden" value="${user.id}" name="userId"/>
<input type="hidden" value="${_csrf.token}" name="_csrf"> <input type="hidden" value="${_csrf.token}" name="_csrf"/>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
</form> </form>

View File

@@ -0,0 +1,80 @@
package net.sytes.kashey.sweater;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.SqlConfig;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
@TestPropertySource("/application-test.properties")
public class LoginTest {
@Autowired
private MockMvc mockMvc;
@Container
private static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("sweater_db_test")
.withUsername("user")
.withPassword("123456");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Test
void guestPageShouldContainLoginMessage() throws Exception {
mockMvc.perform(get("/"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string(containsString("Hello, guest")))
.andExpect(content().string(containsString("Log in")));
}
@Test
void unauthorizedUserShouldBeRedirectedToLogin() throws Exception {
mockMvc.perform(get("/main"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login"));
}
@Test
@Sql(
scripts = "/import.sql",
config = @SqlConfig(encoding = "UTF-8")
)
void authorizedUserShouldBeRedirectedToMainPage() throws Exception {
mockMvc.perform(formLogin().user("user").password("user"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/main"));
}
@Test
void shouldReturnForbiddenWhenLoginWithInvalidCredentials() throws Exception {
mockMvc.perform(post("/login").param("username", "Dummy"))
.andDo(print())
.andExpect(status().isForbidden());
}
}

View File

@@ -0,0 +1,102 @@
package net.sytes.kashey.sweater;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithUserDetails;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.SqlConfig;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath;
@SpringBootTest
@Testcontainers
@AutoConfigureMockMvc
@WithUserDetails(value = "admin")
@TestPropertySource("/application-test.properties")
@Sql(
scripts = {"/import.sql"},
config = @SqlConfig(encoding = "UTF-8")
)
public class MainControllerTest {
@Autowired
private MockMvc mockMvc;
@Container
private static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("sweater_db_test")
.withUsername("user")
.withPassword("123456");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Test
void shouldDisplayUsernameInNavbarWhenUserIsAuthenticated() throws Exception {
mockMvc.perform(get("/main"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(authenticated())
.andExpect(xpath("/html/body/nav/div/div[2]").string("admin"));
}
@Test
void shouldDisplayAllMessagesOnMainPage() throws Exception {
mockMvc.perform(get("/main"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(authenticated())
.andExpect(xpath("//*[@id='message-list']/div").nodeCount(4));
}
@Test
@WithUserDetails(value = "admin")
void shouldFilterMessagesByTag() throws Exception {
mockMvc.perform(get("/main").param("tag", "my-tag"))
.andDo(print())
.andExpect(authenticated())
.andExpect(xpath("//*[@id='message-list']/div").nodeCount(2))
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='1']").exists())
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='3']").exists());
}
@Test
public void shouldAddNewMessageAndDisplayItOnMainPage() throws Exception {
MockHttpServletRequestBuilder multipart = multipart("/main")
.file("file", "123".getBytes())
.param("text", "fifth")
.param("tag", "new one")
.with(csrf());
this.mockMvc.perform(multipart)
.andDo(print())
.andExpect(authenticated())
.andExpect(xpath("//*[@id='message-list']/div").nodeCount(5))
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='5']").exists())
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='5']/div/span").string("fifth"))
.andExpect(xpath("//*[@id='message-list']/div/div[@data-id='5']/div/i").string("#new one"));
}
}

View File

@@ -0,0 +1,19 @@
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3306/sweater_db_test
spring.datasource.username=user
spring.datasource.password=123456
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.freemarker.expose-request-attributes=true
spring.freemarker.expose-session-attributes=true
spring.freemarker.suffix=.ftl
upload.path=/home/kashey/soft/sweater/uploads
hosting.path=http://localhost:8080
spring.mail.host=smtp.yandex.ru
spring.mail.username=username@yandex.ru
spring.mail.password=pa$$word
spring.mail.port=465
spring.mail.protocol=smtps
mail.debug=true
recaptcha.secret=

View File

@@ -0,0 +1,49 @@
DROP TABLE IF EXISTS message;
CREATE TABLE IF NOT EXISTS message
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
filename VARCHAR(255),
tag VARCHAR(255),
text VARCHAR(2048) NOT NULL
) ENGINE = InnoDB;
CREATE TABLE IF NOT EXISTS user_role
(
user_id BIGINT NOT NULL,
roles ENUM ('ADMIN', 'USER')
) ENGINE = InnoDB;
CREATE TABLE IF NOT EXISTS usr
(
enabled bit not null,
id bigint not null,
activation_code varchar(255),
email varchar(255),
password varchar(255) not null,
username varchar(255) not null,
primary key (id)
) engine = InnoDB;
DELETE
FROM user_role;
DELETE
FROM usr;
INSERT INTO usr (id, username, password, enabled)
VALUES (1, 'admin', '$2a$12$.XB1cxFOs6F8vgouixcTP.TaY2GXwFspYHID12JpLwotvoYI0JwnC', true);
INSERT INTO usr (id, username, password, enabled)
VALUES (2, 'user', '$2a$12$K3ShbpKTnDVozTH2HJ.mbu7jXoiy2oqAoVTEF0iRGFxUfJvse3oke', true);
INSERT INTO user_role (user_id, roles)
VALUES (1, 'USER'),
(1, 'ADMIN');
INSERT INTO message (text, tag, user_id)
VALUES ('first', 'my-tag', 1),
('second', 'more', 1),
('third', 'my-tag', 1),
('fourth', 'another', 2);