diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..d3dc7a63 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: zenbook14 + ports: + - "5555:5556" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7 + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + redis_data: \ No newline at end of file diff --git a/pom.xml b/pom.xml index 03bc446d..8e3831cb 100644 --- a/pom.xml +++ b/pom.xml @@ -1,43 +1,170 @@ - 4.0.0 + + com.emobile + SpringToDo + 0.0.1-SNAPSHOT + SpringToDo + Spring Boot ToDo Application + org.springframework.boot spring-boot-starter-parent 3.3.5 - + - com.emobile - SpringToDo - 0.0.1-SNAPSHOT - SpringToDo - SpringToDo 21 + 1.5.5.Final + 2.5.0 + 1.20.2 + + + UTF-8 + UTF-8 + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + runtime + + + + + org.flywaydb + flyway-core + + + + org.flywaydb + flyway-database-postgresql + + + org.springframework.boot - spring-boot-starter + spring-boot-starter-data-redis + + + + org.apache.commons + commons-pool2 + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.projectlombok + lombok + true + + + org.springframework.boot spring-boot-starter-test test + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + src/main/resources + false + + + org.springframework.boot spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + UTF-8 + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + test,hibernate + + + - diff --git a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java index 41980514..d1f04a64 100644 --- a/src/main/java/com/emobile/springtodo/SpringToDoApplication.java +++ b/src/main/java/com/emobile/springtodo/SpringToDoApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +@EnableCaching @SpringBootApplication public class SpringToDoApplication { diff --git a/src/main/java/com/emobile/springtodo/config/HibernateConfig.java b/src/main/java/com/emobile/springtodo/config/HibernateConfig.java new file mode 100644 index 00000000..4ae8367d --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/HibernateConfig.java @@ -0,0 +1,10 @@ +package com.emobile.springtodo.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("hibernate") +public class HibernateConfig { + // пусто — убрали SessionFactory- бин, чтобы не было циклов +} diff --git a/src/main/java/com/emobile/springtodo/config/RedisConfig.java b/src/main/java/com/emobile/springtodo/config/RedisConfig.java new file mode 100644 index 00000000..3441b371 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/RedisConfig.java @@ -0,0 +1,22 @@ +package com.emobile.springtodo.config; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +@Configuration +@Profile("redis") +public class RedisConfig { + + @Bean + public RedisCacheConfiguration cacheConfiguration() { + return RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(new GenericJackson2JsonRedisSerializer())); + } +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/controller/ToDoAPI.java b/src/main/java/com/emobile/springtodo/controller/ToDoAPI.java new file mode 100644 index 00000000..b2e98fdb --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/ToDoAPI.java @@ -0,0 +1,48 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.dto.ToDoDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.responses.ApiResponse; + +import java.util.List; + +@Tag(name = "ToDoAPI", description = "API for managing TODO items") +public interface ToDoAPI { + + @Operation(summary = "Create a new TODO item") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "TODO created"), + @ApiResponse(responseCode = "404", description = "invalid input") + }) + ToDoDto create(ToDoDto dto); + + @Operation(summary = "Get TODO item by ID") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "TODO found"), + @ApiResponse(responseCode = "404", description = "TODO not found") + }) + ToDoDto getById(Long id); + + @Operation(summary = "Get all TODO items with pagination") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "List of TODOs") + }) + List getAll(int limit, int offset); + + @Operation(summary = "Update TODO item") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "TODO updated"), + @ApiResponse(responseCode = "404", description = "TODO not found"), + @ApiResponse(responseCode = "400", description = "Invalid input") + }) + ToDoDto update(Long id, ToDoDto dto); + + @Operation(summary = "Delete TODO item") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "TODO deleted"), + @ApiResponse(responseCode = "404", description = "TODO not found") + }) + void delete(Long id); +} diff --git a/src/main/java/com/emobile/springtodo/controller/ToDoController.java b/src/main/java/com/emobile/springtodo/controller/ToDoController.java new file mode 100644 index 00000000..45ed6ef4 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/controller/ToDoController.java @@ -0,0 +1,45 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.dto.ToDoDto; +import com.emobile.springtodo.service.ToDoService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("api/todos") +public class ToDoController implements ToDoAPI { + + private final ToDoService service; + + public ToDoController(ToDoService service) { + this.service = service; + } + + @PostMapping + public ToDoDto create(@Valid @RequestBody ToDoDto dto){ + return service.create(dto); + } + + @GetMapping("/{id}") + public ToDoDto getById(@PathVariable Long id){ + return service.findById(id); + } + + @GetMapping + public List getAll(@RequestParam(defaultValue = "10") int limit, + @RequestParam(defaultValue = "0") int offset){ + return service.findAll(limit, offset); + } + + @PutMapping("/{id}") + public ToDoDto update(@PathVariable Long id, @Valid @RequestBody ToDoDto dto){ + return service.update(id, dto); + } + + @DeleteMapping("/{id}") + public void delete(@PathVariable Long id){ + service.delete(id); + } +} diff --git a/src/main/java/com/emobile/springtodo/dto/ToDoDto.java b/src/main/java/com/emobile/springtodo/dto/ToDoDto.java new file mode 100644 index 00000000..99028151 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/ToDoDto.java @@ -0,0 +1,96 @@ +//package com.emobile.springtodo.dto; +// +//import jakarta.validation.constraints.NotBlank; +//import jakarta.validation.constraints.Size; +// +//import java.io.Serializable; +// +//public class ToDoDto implements Serializable { +// private static final long serialVersionUID = 1L; +// +// private Long id; +// +// @NotBlank(message = "Title is mandatory") +// @Size(max = 100, message = "Title must not exceed 100 characters") +// private String title; +// +// @Size(max = 500, message = "Description must not exceed 500 characters") +// private String description; +// +// private boolean completed; // был Boolean +// +// public ToDoDto() { +// } +// +// public ToDoDto(Long id, String title, String description, boolean completed) { +// this.id = id; +// this.title = title; +// this.description = description; +// this.completed = completed; +// } +// +// public Long getId() { +// return id; +// } +// +// public void setId(Long id) { +// this.id = id; +// } +// +// public boolean isCompleted() { +// return completed; +// } +// +// public void setCompleted(boolean completed) { +// this.completed = completed; +// } +// +// public String getDescription() { +// return description; +// } +// +// public void setDescription(String description) { +// this.description = description; +// } +// +// public String getTitle() { +// return title; +// } +// +// public void setTitle(String title) { +// this.title = title; +// } +//} +package com.emobile.springtodo.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.io.Serializable; + +public class ToDoDto implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + + @NotBlank(message = "Title is mandatory") + @Size(max = 100, message = "Title must not exceed 100 characters") + private String title; + + @Size(max = 500, message = "Description must not exceed 500 characters") + private String description; + + private Boolean completed; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Boolean getCompleted() { return completed; } + public void setCompleted(Boolean completed) { this.completed = completed; } +} + diff --git a/src/main/java/com/emobile/springtodo/entity/ToDoEntity.java b/src/main/java/com/emobile/springtodo/entity/ToDoEntity.java new file mode 100644 index 00000000..0e6f9a6a --- /dev/null +++ b/src/main/java/com/emobile/springtodo/entity/ToDoEntity.java @@ -0,0 +1,59 @@ +package com.emobile.springtodo.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "todos") +public class ToDoEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 255) + private String title; + + @Column(columnDefinition = "text") + private String description; + + @Column(nullable = false) + private boolean completed = false; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public boolean isCompleted() { return completed; } + public Boolean getCompleted() { return completed; } + public void setCompleted(boolean completed) { this.completed = completed; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java b/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..fa4015c1 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java @@ -0,0 +1,39 @@ +package com.emobile.springtodo.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ToDoNotFoundException.class) + public ResponseEntity> handleNotFound(ToDoNotFoundException ex) { + Map error = new HashMap<>(); + error.put("error", ex.getMessage()); + return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(error -> + errors.put(error.getField(), error.getDefaultMessage())); + return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception ex) { + ex.printStackTrace(); + Map errors = new HashMap<>(); + errors.put("error", ex.getMessage()); + return new ResponseEntity<>(errors, HttpStatus.INTERNAL_SERVER_ERROR); + } +} + diff --git a/src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java b/src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java new file mode 100644 index 00000000..8006d6fd --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java @@ -0,0 +1,7 @@ +package com.emobile.springtodo.exception; + +public class ToDoNotFoundException extends RuntimeException{ + public ToDoNotFoundException(String message){ + super(message); + } +} diff --git a/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java new file mode 100644 index 00000000..03437f7b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java @@ -0,0 +1,16 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.dto.ToDoDto; +import com.emobile.springtodo.entity.ToDoEntity; + +import org.mapstruct.Mapping; + +@org.mapstruct.Mapper(componentModel = "spring") +public interface ToDoMapper { + // @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + ToDoEntity toEntity(ToDoDto dto); + + ToDoDto toDto(ToDoEntity entity); +} diff --git a/src/main/java/com/emobile/springtodo/repository/ToDoJpaRepository.java b/src/main/java/com/emobile/springtodo/repository/ToDoJpaRepository.java new file mode 100644 index 00000000..129dc560 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/ToDoJpaRepository.java @@ -0,0 +1,11 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.entity.ToDoEntity; +import org.springframework.context.annotation.Profile; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +@Profile("datajpa") +public interface ToDoJpaRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java new file mode 100644 index 00000000..011aac3b --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java @@ -0,0 +1,55 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.entity.ToDoEntity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Repository +@Profile("hibernate") +@Transactional(readOnly = true) +public class ToDoRepository { + + @PersistenceContext + private EntityManager em; + + @Transactional + public ToDoEntity save(ToDoEntity entity) { + if (entity.getId() == null) { + em.persist(entity); + return entity; + } else { + return em.merge(entity); + } + } + + public Optional findById(Long id) { + return Optional.ofNullable(em.find(ToDoEntity.class, id)); + } + + public List findAll(int limit, int offset) { + return em.createQuery("from ToDoEntity e order by e.id", ToDoEntity.class) + .setFirstResult(offset) + .setMaxResults(limit) + .getResultList(); + } + + @Transactional + public void deleteByID(Long id) { + ToDoEntity e = em.find(ToDoEntity.class, id); + if (e != null) { + em.remove(e); + } + } + + public long count() { + Long c = em.createQuery("select count(e.id) from ToDoEntity e", Long.class) + .getSingleResult(); + return c == null ? 0 : c; + } +} diff --git a/src/main/java/com/emobile/springtodo/service/ToDoService.java b/src/main/java/com/emobile/springtodo/service/ToDoService.java new file mode 100644 index 00000000..5bb08b89 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/ToDoService.java @@ -0,0 +1,148 @@ +//package com.emobile.springtodo.service; +// +//import com.emobile.springtodo.dto.ToDoDto; +//import com.emobile.springtodo.entity.ToDoEntity; +//import com.emobile.springtodo.exception.ToDoNotFoundException; +//import com.emobile.springtodo.mapper.ToDoMapper; +//import com.emobile.springtodo.repository.ToDoRepository; +//import org.springframework.cache.annotation.CacheEvict; +//import org.springframework.cache.annotation.Cacheable; +//import org.springframework.stereotype.Service; +//import org.springframework.transaction.annotation.Transactional; +// +//import java.util.List; +//import java.util.stream.Collectors; +// +//@Service +//public class ToDoService { +// +// private final ToDoRepository repository; +// private final ToDoMapper mapper; +// +// public ToDoService(ToDoRepository repository, ToDoMapper mapper) { +// this.repository = repository; +// this.mapper = mapper; +// } +// @Transactional +// @CacheEvict(value = "postgres", allEntries = true) +// public ToDoDto create(ToDoDto dto){ +// ToDoEntity entity = mapper.toEntity(dto); +// entity = repository.save(entity); +// return mapper.toDto(entity); +// } +// @Transactional +// @Cacheable(value = "postgres", key = "#id") +// public ToDoDto findById(Long id){ +// ToDoEntity entity = repository.findById(id) +// .orElseThrow(()-> new ToDoNotFoundException("ToDo not found with id: "+ id)); +// return mapper.toDto(entity); +// } +// @Transactional +// @Cacheable(value = "postgres") +// public List findAll(int limit, int offset){ +// return repository.findAll(limit,offset).stream() +// .map(mapper::toDto) +// .collect(Collectors.toList()); +// } +// @Transactional +// @CacheEvict(value = "postgres", allEntries = true) +// public ToDoDto update(Long id, ToDoDto dto){ +// ToDoEntity entity = repository.findById(id) +// .orElseThrow(() -> new ToDoNotFoundException("ToDo not found with id: " + id)); +// +// entity.setTitle(dto.getTitle()); +// entity.setDescription(dto.getDescription()); +// +// entity.setCompleted(dto.isCompleted()); +// +// entity = repository.save(entity); +// return mapper.toDto(entity); +// } +// @Transactional +// @CacheEvict(value = "postgres", allEntries = true) +// public void delete(Long id){ +// if(!repository.findById(id).isPresent()){ +// throw new ToDoNotFoundException("ToDo not found with id: "+ id); +// } +// repository.deleteByID(id); +// } +//} + +package com.emobile.springtodo.service; + +import com.emobile.springtodo.dto.ToDoDto; +import com.emobile.springtodo.entity.ToDoEntity; +import com.emobile.springtodo.exception.ToDoNotFoundException; +import com.emobile.springtodo.mapper.ToDoMapper; +import com.emobile.springtodo.repository.ToDoJpaRepository; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Profile; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Profile("datajpa") +public class ToDoService { + + private final ToDoJpaRepository repository; + private final ToDoMapper mapper; + + public ToDoService(ToDoJpaRepository repository, ToDoMapper mapper) { + this.repository = repository; + this.mapper = mapper; + } + + @Transactional + @CacheEvict(value = "postgres", allEntries = true) + public ToDoDto create(ToDoDto dto) { + ToDoEntity entity = mapper.toEntity(dto); + entity = repository.save(entity); + return mapper.toDto(entity); + } + + @Transactional(readOnly = true) + @Cacheable(value = "postgres", key = "#id") + public ToDoDto findById(Long id) { + ToDoEntity entity = repository.findById(id) + .orElseThrow(() -> new ToDoNotFoundException("ToDo not found with id: " + id)); + return mapper.toDto(entity); + } + + @Transactional(readOnly = true) + @Cacheable(value = "postgres") + public List findAll(int limit, int offset) { + int page = (limit <= 0) ? 0 : Math.max(0, offset / limit); + var pageable = PageRequest.of(page, Math.max(1, limit), Sort.by(Sort.Direction.ASC, "id")); + return repository.findAll(pageable).stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } + + @Transactional + @CacheEvict(value = "postgres", allEntries = true) + public ToDoDto update(Long id, ToDoDto dto) { + ToDoEntity entity = repository.findById(id) + .orElseThrow(() -> new ToDoNotFoundException("ToDo not found with id: " + id)); + entity.setTitle(dto.getTitle()); + entity.setDescription(dto.getDescription()); + entity.setCompleted(dto.getCompleted()); + entity = repository.save(entity); + return mapper.toDto(entity); + } + + @Transactional + @CacheEvict(value = "postgres", allEntries = true) + public void delete(Long id) { + if (!repository.existsById(id)) { + throw new ToDoNotFoundException("ToDo not found with id: " + id); + } + repository.deleteById(id); + } +} + diff --git a/src/main/resources/application-datajpa.properties b/src/main/resources/application-datajpa.properties new file mode 100644 index 00000000..8ecccbef --- /dev/null +++ b/src/main/resources/application-datajpa.properties @@ -0,0 +1,24 @@ +# --- DataSource (Postgres ? Docker, ???? ????????? 5432 -> 5432) --- +spring.datasource.url=jdbc:postgresql://localhost:5432/postgres +spring.datasource.username=postgres +spring.datasource.password=zenbook14 +spring.datasource.hikari.maximum-pool-size=5 + +# --- JPA/Hibernate --- +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=true + +# --- Flyway (??????? ??? ?? DataSource) --- +spring.flyway.enabled=true + + +#spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration +#spring.cache.type=simple + + +spring.cache.type=redis +spring.data.redis.host=localhost +spring.data.redis.port=6379 + +# spring.cache.redis.time-to-live=600s diff --git a/src/main/resources/application-hibernate.properties b/src/main/resources/application-hibernate.properties new file mode 100644 index 00000000..67297184 --- /dev/null +++ b/src/main/resources/application-hibernate.properties @@ -0,0 +1,4 @@ + +logging.level.org.hibernate.SQL=debug +logging.level.org.hibernate.orm.jdbc.bind=trace +spring.jpa.properties.hibernate.format_sql=true diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties new file mode 100644 index 00000000..4620dc2a --- /dev/null +++ b/src/main/resources/application-local.properties @@ -0,0 +1,66 @@ +# +## DataSource (PostgreSQL) +# +#spring.datasource.url=jdbc:postgresql://localhost:5432/postgres +#spring.datasource.username=postgres +#spring.datasource.password=zenbook14 +#spring.datasource.hikari.maximum-pool-size=5 +#spring.datasource.hikari.minimum-idle=2 +# +## JPA / Hibernate +# +#spring.jpa.hibernate.ddl-auto=validate +#spring.jpa.open-in-view=false +#spring.jpa.show-sql=false +#spring.jpa.properties.hibernate.format_sql=true +# +## Flyway +# +#spring.flyway.enabled=true +## spring.flyway.locations=classpath:db/migration +## Cache = Redis +# +#spring.cache.type=redis +# +## Redis +#spring.data.redis.host=localhost +#spring.data.redis.port=6379 +#spring.data.redis.timeout=2s +# +#spring.cache.redis.time-to-live=600s +#spring.cache.redis.use-key-prefix=true +#spring.cache.redis.key-prefix=todo:: +#spring.cache.redis.cache-null-values=false +# +## Logging +## logging.level.org.springframework.cache=DEBUG +## logging.level.org.springframework.data.redis=DEBUG +## logging.level.org.hibernate.SQL=DEBUG +## logging.level.org.hibernate.orm.jdbc.bind=TRACE + +# --- DataSource +spring.datasource.url=jdbc:postgresql://localhost:5432/postgres +spring.datasource.username=postgres +spring.datasource.password=zenbook14 +spring.datasource.hikari.maximum-pool-size=5 + +# --- JPA/Hibernate --- +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.open-in-view=false +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=true + +# --- Flyway --- +spring.flyway.enabled=true + +# --- Кэш / Redis --- +spring.cache.type=redis +spring.data.redis.host=localhost +spring.data.redis.port=6379 +spring.cache.redis.time-to-live=600s +spring.cache.redis.key-prefix=springtodo:: +spring.cache.redis.use-key-prefix=true + +# логирование +# logging.level.org.springframework.cache=DEBUG +# logging.level.org.springframework.data.redis=INFO diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties new file mode 100644 index 00000000..04d0ac75 --- /dev/null +++ b/src/main/resources/application-test.properties @@ -0,0 +1,46 @@ +# +#spring.datasource.url=jdbc:tc:postgresql:16.8://localhost:5432/postgres +#spring.datasource.username=postgres +#spring.datasource.password=zenbook14 +#spring.datasource.driver-class-name=org.postgresql.Driver +# +#spring.flyway.enabled=true +#spring.flyway.locations=classpath:db/migration +# +#spring.jpa.hibernate.ddl-auto=validate +#spring.jpa.open-in-view=false +#spring.jpa.properties.hibernate.format_sql=true +#spring.jpa.properties.hibernate.jdbc.time_zone=UTC +# +#spring.cache.type=simple +#spring.data.redis.host=localhost +#spring.data.redis.port=6379 + + + +spring.profiles.include=datajpa + +# --- Отдельная БД для тестов --- +spring.datasource.url=jdbc:postgresql://localhost:5432/todo_test +spring.datasource.username=postgres +spring.datasource.password=zenbook14 +spring.datasource.hikari.maximum-pool-size=3 + +# --- JPA --- +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.open-in-view=false +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.format_sql=true + +# --- Flyway --- +spring.flyway.enabled=true +spring.flyway.clean-disabled=false +spring.flyway.clean-on-validation-error=true + +# --- Redis cache --- +spring.cache.type=redis +spring.data.redis.host=localhost +spring.data.redis.port=6379 +spring.cache.redis.time-to-live=600s +spring.cache.redis.key-prefix=test:springtodo:: +spring.cache.redis.use-key-prefix=true diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5c3e5461..b42def68 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,12 @@ -spring.application.name=SpringToDo +spring.application.name=SpringToDo + +server.port=8080 +spring.jpa.open-in-view=false + +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration + +spring.flyway.baseline-on-migrate=true + +logging.level.org.springframework=info +logging.level.com.emobile.springtodo=debug diff --git a/src/main/resources/db/migration/V1__create_todos_table.sql b/src/main/resources/db/migration/V1__create_todos_table.sql new file mode 100644 index 00000000..25a019c5 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_todos_table.sql @@ -0,0 +1,11 @@ +DROP TABLE IF EXISTS todos; + +CREATE TABLE todos +( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT, + completed BOOLEAN NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V2__todos_id_to_bigint.sql b/src/main/resources/db/migration/V2__todos_id_to_bigint.sql new file mode 100644 index 00000000..3ce3e9d7 --- /dev/null +++ b/src/main/resources/db/migration/V2__todos_id_to_bigint.sql @@ -0,0 +1,3 @@ +-- Переводим первичный ключ на BIGINT +ALTER TABLE public.todos +ALTER COLUMN id TYPE BIGINT; diff --git a/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java b/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java new file mode 100644 index 00000000..406616b8 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java @@ -0,0 +1,174 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.SpringToDoApplication; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@Testcontainers +@SpringBootTest(classes = SpringToDoApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class ToDoControllerTest { + + @Container + private static final PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16.8") + .withDatabaseName("postgres") + .withUsername("postgres") + .withPassword("zenbook14"); + + @Autowired + private MockMvc mockMvc; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgresContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgresContainer::getUsername); + registry.add("spring.datasource.password", postgresContainer::getPassword); + registry.add("spring.flyway.enabled", () -> "true"); + registry.add("spring.flyway.locations", () -> "classpath:db/migration"); + } + + @BeforeEach + void setUp() { + Flyway flyway = Flyway.configure() + .dataSource(postgresContainer.getJdbcUrl(), postgresContainer.getUsername(), postgresContainer.getPassword()) + .locations("classpath:db/migration") + .cleanDisabled(false) + .load(); + flyway.clean(); + flyway.migrate(); + } + + @Test + @DisplayName("Should create and retrieve ToDo by ID") + @Sql("/insert-todo.sql") // Подготовка данных + void shouldCreateAndGetToDoById() throws Exception { + String todoJson = "{\"title\":\"Test ToDo\",\"description\":\"Test Description\",\"completed\":false}"; + String expectedJson = "{\"id\":1,\"title\":\"Test ToDo\",\"description\":\"Test Description\",\"completed\":false}"; + + // Создаём ToDo + MvcResult createResult = mockMvc.perform(post("/api/todos") + .contentType(MediaType.APPLICATION_JSON) + .content(todoJson)) + .andExpect(status().isOk()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, createResult.getResponse().getContentAsString(), false); + + // Проверяем получение по ID + MvcResult getResult = mockMvc.perform(get("/api/todos/1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, getResult.getResponse().getContentAsString(), false); + } + + @Test + @DisplayName("Should return 404 for non-existent ToDo") + void shouldReturn404ForNonExistentToDo() throws Exception { + String expectedErrorJson = "{\"error\":\"ToDo not found with id: 999\"}"; + + MvcResult result = mockMvc.perform(get("/api/todos/999") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andReturn(); + + JSONAssert.assertEquals(expectedErrorJson, result.getResponse().getContentAsString(), false); + } + + @Test + void shouldValidateToDoInput() throws Exception { + String invalidToDoJson = "{\"title\":\"\",\"description\":\"Test Description\",\"completed\":false}"; + mockMvc.perform(post("/api/todos") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidToDoJson)) + .andExpect(status().isBadRequest()) + .andExpect(content().json("{\"title\":\"Title is mandatory\"}")); + } + + @Test + @DisplayName("Should return paginated list of ToDos") + @Sql("/insert-multiple-todos.sql") // Вставка нескольких записей + void shouldReturnPaginatedToDos() throws Exception { + String expectedJson = """ + { + "content": [ + {"id":1,"title":"ToDo 1","description":"Desc 1","completed":false}, + {"id":2,"title":"ToDo 2","description":"Desc 2","completed":true} + ], + "totalElements": 2, + "totalPages": 1, + "page": 0, + "size": 10 + } + """; + + MvcResult result = mockMvc.perform(get("/api/todos?limit=10&offset=0") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, result.getResponse().getContentAsString(), false); + } + + @Test + @DisplayName("Should cache ToDo retrieval") + void shouldCacheToDoRetrieval() throws Exception { + String todoJson = "{\"title\":\"Cached ToDo\",\"description\":\"Cached Description\",\"completed\":false}"; + String expectedJson = "{\"id\":1,\"title\":\"Cached ToDo\",\"description\":\"Cached Description\",\"completed\":false}"; + + // Создаём ToDo + mockMvc.perform(post("/api/todos") + .contentType(MediaType.APPLICATION_JSON) + .content(todoJson)) + .andExpect(status().isOk()); + + // Первый вызов - должен попасть в базу + MvcResult firstCall = mockMvc.perform(get("/api/todos/1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, firstCall.getResponse().getContentAsString(), false); + + // Второй вызов - должен взять из кеша + MvcResult secondCall = mockMvc.perform(get("/api/todos/1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + JSONAssert.assertEquals(expectedJson, secondCall.getResponse().getContentAsString(), false); + } + + @Test + @DisplayName("Should return Swagger API documentation") + void shouldReturnSwaggerDocumentation() throws Exception { + MvcResult result = mockMvc.perform(get("/v3/api-docs") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn(); + + String response = result.getResponse().getContentAsString(); + JSONAssert.assertEquals("{\"openapi\":\"3.0.1\"}", response, false); + } +} diff --git a/src/test/resources/insert-multiple-todos.sql b/src/test/resources/insert-multiple-todos.sql new file mode 100644 index 00000000..2a3b156f --- /dev/null +++ b/src/test/resources/insert-multiple-todos.sql @@ -0,0 +1,4 @@ +INSERT INTO todos (title, description, completed, created_at, updated_at) +VALUES + ('ToDo 1', 'Desc 1', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('ToDo 2', 'Desc 2', true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); \ No newline at end of file diff --git a/src/test/resources/insert-todo.sql b/src/test/resources/insert-todo.sql new file mode 100644 index 00000000..2b8c3fa2 --- /dev/null +++ b/src/test/resources/insert-todo.sql @@ -0,0 +1,2 @@ +INSERT INTO todos (title, description, completed, created_at, updated_at) +VALUES ('Test ToDo', 'Test Description', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); \ No newline at end of file diff --git a/src/test/resources/test-data.sql b/src/test/resources/test-data.sql new file mode 100644 index 00000000..39de44f9 --- /dev/null +++ b/src/test/resources/test-data.sql @@ -0,0 +1,2 @@ +INSERT INTO postgres (id, title, description, completed, created_at, updated_at) +VALUES (1, 'Sample Todo', 'Sample Description', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); \ No newline at end of file