From 9ab0dc423be100d0d0accac66e95c6964c42ab3f Mon Sep 17 00:00:00 2001 From: Spire496 Date: Wed, 7 May 2025 23:10:01 +0500 Subject: [PATCH 1/3] Commit #1 --- pom.xml | 113 +++++++++++++++++- .../springtodo/SpringToDoApplication.java | 2 + .../springtodo/controller/ToDoAPI.java | 48 ++++++++ .../springtodo/controller/ToDoController.java | 45 +++++++ .../com/emobile/springtodo/dto/ToDoDto.java | 52 ++++++++ .../emobile/springtodo/entity/ToDoEntity.java | 64 ++++++++++ .../exception/GlobalExceptionHandler.java | 38 ++++++ .../exception/ToDoNotFoundException.java | 7 ++ .../emobile/springtodo/mapper/ToDoMapper.java | 15 +++ .../springtodo/repository/ToDoRepository.java | 59 +++++++++ .../springtodo/service/ToDoService.java | 65 ++++++++++ src/main/resources/application.properties | 8 ++ .../migration/V1__create_postgres_table.sql | 8 ++ .../controller/ToDoControllerTest.java | 85 +++++++++++++ src/test/resources/test-data.sql | 2 + 15 files changed, 607 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/emobile/springtodo/controller/ToDoAPI.java create mode 100644 src/main/java/com/emobile/springtodo/controller/ToDoController.java create mode 100644 src/main/java/com/emobile/springtodo/dto/ToDoDto.java create mode 100644 src/main/java/com/emobile/springtodo/entity/ToDoEntity.java create mode 100644 src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/emobile/springtodo/exception/ToDoNotFoundException.java create mode 100644 src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java create mode 100644 src/main/java/com/emobile/springtodo/repository/ToDoRepository.java create mode 100644 src/main/java/com/emobile/springtodo/service/ToDoService.java create mode 100644 src/main/resources/db/migration/V1__create_postgres_table.sql create mode 100644 src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java create mode 100644 src/test/resources/test-data.sql diff --git a/pom.xml b/pom.xml index 03bc446d..6a187e3a 100644 --- a/pom.xml +++ b/pom.xml @@ -16,19 +16,99 @@ 21 + 10.22.0 + 1.6.0 + 1.18.34 + 2.6.0 + org.springframework.boot - spring-boot-starter + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-validation - org.springframework.boot spring-boot-starter-test test + + + + org.postgresql + postgresql + runtime + + + + + org.flywaydb + flyway-core + ${flyway.version} + + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + provided + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.testcontainers + postgresql + 1.20.2 + test + + + org.testcontainers + junit-jupiter + 1.20.2 + test + @@ -37,7 +117,32 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + ${java.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + - - + \ No newline at end of file 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/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..c50250d6 --- /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..cb801ad9 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/dto/ToDoDto.java @@ -0,0 +1,52 @@ +package com.emobile.springtodo.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + + + +public class ToDoDto { + + 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 Boolean getCompleted() { + 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; + } +} 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..9ed44d28 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/entity/ToDoEntity.java @@ -0,0 +1,64 @@ +package com.emobile.springtodo.entity; + + + +import java.time.LocalDateTime; + + +public class ToDoEntity { + + private Long id; + private String title; + private String description; + private boolean completed; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + 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; + } +} 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..952265fa --- /dev/null +++ b/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java @@ -0,0 +1,38 @@ +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) { + Map errors = new HashMap<>(); + errors.put("error", "Internal Server Error"); + 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..de3d7714 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java @@ -0,0 +1,15 @@ +package com.emobile.springtodo.mapper; + +import com.emobile.springtodo.dto.ToDoDto; +import com.emobile.springtodo.entity.ToDoEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@org.mapstruct.Mapper(componentModel = "spring") +public interface ToDoMapper { + @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/ToDoRepository.java b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java new file mode 100644 index 00000000..78148c94 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java @@ -0,0 +1,59 @@ +package com.emobile.springtodo.repository; + +import com.emobile.springtodo.entity.ToDoEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public class ToDoRepository { + + private final JdbcTemplate jdbcTemplate; + + public ToDoRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + private RowMapper rowMapper = (rs, rowNum) -> { + ToDoEntity entity = new ToDoEntity(); + entity.setId(rs.getLong("id")); + entity.setTitle(rs.getString("title")); + entity.setDescription(rs.getString("description")); + entity.setCompleted(rs.getBoolean("completed")); + entity.setCreatedAt(rs.getObject("created_at", LocalDateTime.class)); + entity.setUpdatedAt(rs.getObject("updated_at", LocalDateTime.class)); + return entity; + }; + + public ToDoEntity save(ToDoEntity entity) { + if (entity.getId() == null) { + String sql = "INSERT INTO postgres(title, description, completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?) RETURNING id"; + Long id = jdbcTemplate.queryForObject(sql, Long.class, entity.getTitle(), entity.getDescription(), entity.isCompleted(), LocalDateTime.now(), LocalDateTime.now()); + entity.setId(id); + }else{ + String sql = "UPDATE postgres SET title = ?, description = ?, completed = ?, updated_at = ? WHERE id = ?"; + jdbcTemplate.update(sql, entity.getTitle(), entity.getDescription(), entity.isCompleted(), LocalDateTime.now(), entity.getId()); + } + return entity; + } + + public Optional findById(Long id){ + String sql = "SELECT * FROM postgres WHERE id = ?"; + return jdbcTemplate.query(sql, rowMapper, id).stream().findFirst(); + } + + public List findAll(int limit, int offset){ + String sql = "SELECT * FROM postgres ORDER BY LIMIT ?, OFFSET ?"; + return jdbcTemplate.query(sql, rowMapper, limit, offset); + } + + public void deleteByID(Long id){ + String sql = "DELETE FROM postgres WHERE id = ?"; + jdbcTemplate.update(sql, id); + } +} 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..8ad463f4 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/ToDoService.java @@ -0,0 +1,65 @@ +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 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; + } + + @CacheEvict(value = "postgres", allEntries = true) + public ToDoDto create(ToDoDto dto){ + ToDoEntity entity = mapper.toEntity(dto); + entity = repository.save(entity); + return mapper.toDto(entity); + } + + @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); + } + + @Cacheable(value = "postgres") + public List findAll(int limit, int offset){ + return repository.findAll(limit,offset).stream() + .map(mapper::toDto) + .collect(Collectors.toList()); + } + + @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); + } + + @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); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 5c3e5461..7a6047ba 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,9 @@ spring.application.name=SpringToDo +spring.datasource.url=jdbc:postgresql://localhost:5432/postgres +spring.datasource.username=postgres +spring.datasource.password= +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration +spring.cache.type=redis +spring.data.redis.host=localhost +spring.data.redis.port=6379 diff --git a/src/main/resources/db/migration/V1__create_postgres_table.sql b/src/main/resources/db/migration/V1__create_postgres_table.sql new file mode 100644 index 00000000..e1c3c675 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_postgres_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE postgres ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(100) NOT NULL, + description VARCHAR(500), + completed BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL, + update_at TIMESTAMP NOT NULL +); \ No newline at end of file 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..e5476bf8 --- /dev/null +++ b/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java @@ -0,0 +1,85 @@ +package com.emobile.springtodo.controller; + +import com.emobile.springtodo.dto.ToDoDto; +import org.junit.jupiter.api.DisplayName; +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.jdbc.Sql; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.MediaType; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +public class ToDoControllerTest { + @Container + static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>("postgres:16.0") + .withDatabaseName("postgres") + .withUsername("postgres") + .withPassword("password"); + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("Should create a new TODO item") + @Sql("/test-data.sql") + void shouldGetToDOById() throws Exception{ + ToDoDto dto = new ToDoDto(); + dto.setTitle("Test ToDo"); + dto.setDescription("Test Description"); + dto.setCompleted(false); + + mockMvc.perform(post("/api/todos") + .contentType(MediaType.APPLICATION_JSON) + .contentType(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Test Todo")) + .andExpect(jsonPath("$.description").value("Test Description")) + .andExpect(jsonPath("$.completed").value(false)); + } + + @Test + @DisplayName("Should get TODO item by ID") + @Sql("/test-data.sql") + void shouldGetToDoByID() throws Exception{ + mockMvc.perform(get("/api/todos/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("1")) + .andExpect(jsonPath("$.title").value("Sample Todo")); + } + + @Test + @DisplayName("Should return 404 for non-existent TODO") + void shouldReturn404ForNonExistentToDo() throws Exception{ + mockMvc.perform(get("/api/todos/999")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("ToDo not found with id: 999")); + + } + + @Test + @DisplayName("Should validate ToDo input") + void shouldValidateToDoInput() throws Exception{ + ToDoDto dto = new ToDoDto(); + dto.setTitle(""); + + mockMvc.perform(post("/api/todos") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").value("Title is mandatory")); + } +} diff --git a/src/test/resources/test-data.sql b/src/test/resources/test-data.sql new file mode 100644 index 00000000..3dc949b4 --- /dev/null +++ b/src/test/resources/test-data.sql @@ -0,0 +1,2 @@ +INSERT INTO todos (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 From 5b63dd59b20385219a69131d2a3404b5deb26e17 Mon Sep 17 00:00:00 2001 From: Spire496 Date: Fri, 9 May 2025 21:42:49 +0500 Subject: [PATCH 2/3] Commit #2 fixed --- docker-compose.yml | 32 +++++ pom.xml | 120 +++++++----------- .../springtodo/config/RedisConfig.java | 18 +++ .../springtodo/controller/ToDoController.java | 4 +- .../exception/GlobalExceptionHandler.java | 3 +- .../emobile/springtodo/mapper/ToDoMapper.java | 2 +- .../springtodo/repository/ToDoRepository.java | 36 ++++-- .../springtodo/service/ToDoService.java | 11 +- .../resources/application-local.properties | 5 + src/main/resources/application.properties | 6 +- .../migration/V1__create_postgres_table.sql | 8 -- .../db/migration/V1__create_todos_table.sql | 8 ++ .../controller/ToDoControllerTest.java | 87 ++++++------- src/test/resources/test-data.sql | 2 +- 14 files changed, 191 insertions(+), 151 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/main/java/com/emobile/springtodo/config/RedisConfig.java create mode 100644 src/main/resources/application-local.properties delete mode 100644 src/main/resources/db/migration/V1__create_postgres_table.sql create mode 100644 src/main/resources/db/migration/V1__create_todos_table.sql 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 6a187e3a..af01b766 100644 --- a/pom.xml +++ b/pom.xml @@ -1,113 +1,97 @@ - 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 - 10.22.0 - 1.6.0 - 1.18.34 - 2.6.0 + 1.6.2 - + org.springframework.boot spring-boot-starter-web + org.springframework.boot spring-boot-starter-jdbc - - org.springframework.boot - spring-boot-starter-data-redis - + org.springframework.boot spring-boot-starter-validation + org.springframework.boot - spring-boot-starter-test - test - - - - - org.postgresql - postgresql - runtime + spring-boot-starter-data-redis - - + org.flywaydb flyway-core - ${flyway.version} + 9.22.3 - - + - org.mapstruct - mapstruct - ${mapstruct.version} + org.postgresql + postgresql + 42.7.4 + - org.mapstruct - mapstruct-processor - ${mapstruct.version} - provided + org.springframework.boot + spring-boot-starter-test + test - - + - org.projectlombok - lombok - ${lombok.version} - provided + org.testcontainers + postgresql + 1.20.2 + test - - + - org.projectlombok - lombok-mapstruct-binding - 0.2.0 - provided + org.testcontainers + junit-jupiter + 1.20.2 + test - org.springdoc springdoc-openapi-starter-webmvc-ui - ${springdoc.version} + 2.6.0 - - + - org.testcontainers - postgresql - 1.20.2 - test + org.mapstruct + mapstruct + ${mapstruct.version} - org.testcontainers - junit-jupiter - 1.20.2 - test + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided @@ -122,24 +106,14 @@ maven-compiler-plugin 3.13.0 - ${java.version} - ${java.version} + 21 + 21 org.mapstruct mapstruct-processor ${mapstruct.version} - - org.projectlombok - lombok - ${lombok.version} - - - org.projectlombok - lombok-mapstruct-binding - 0.2.0 - 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..b2273b46 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/RedisConfig.java @@ -0,0 +1,18 @@ +package com.emobile.springtodo.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +@Configuration +public class RedisConfig { + + @Bean + public RedisCacheConfiguration cacheConfiguration() { + return RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith(RedisSerializationContext.SerializationPair + .fromSerializer(new GenericJackson2JsonRedisSerializer())); + } +} diff --git a/src/main/java/com/emobile/springtodo/controller/ToDoController.java b/src/main/java/com/emobile/springtodo/controller/ToDoController.java index c50250d6..45ed6ef4 100644 --- a/src/main/java/com/emobile/springtodo/controller/ToDoController.java +++ b/src/main/java/com/emobile/springtodo/controller/ToDoController.java @@ -33,12 +33,12 @@ public List getAll(@RequestParam(defaultValue = "10") int limit, return service.findAll(limit, offset); } - @PutMapping("/id") + @PutMapping("/{id}") public ToDoDto update(@PathVariable Long id, @Valid @RequestBody ToDoDto dto){ return service.update(id, dto); } - @DeleteMapping("/id") + @DeleteMapping("/{id}") public void delete(@PathVariable Long id){ service.delete(id); } diff --git a/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java b/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java index 952265fa..fa4015c1 100644 --- a/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/emobile/springtodo/exception/GlobalExceptionHandler.java @@ -30,8 +30,9 @@ public ResponseEntity> handleValidation(MethodArgumentNotVal @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception ex) { + ex.printStackTrace(); Map errors = new HashMap<>(); - errors.put("error", "Internal Server Error"); + errors.put("error", ex.getMessage()); return new ResponseEntity<>(errors, HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java index de3d7714..74e23321 100644 --- a/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java +++ b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java @@ -2,7 +2,7 @@ import com.emobile.springtodo.dto.ToDoDto; import com.emobile.springtodo.entity.ToDoEntity; -import org.mapstruct.Mapper; + import org.mapstruct.Mapping; @org.mapstruct.Mapper(componentModel = "spring") diff --git a/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java index 78148c94..c07f5654 100644 --- a/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java +++ b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java @@ -30,30 +30,42 @@ public ToDoRepository(JdbcTemplate jdbcTemplate) { return entity; }; - public ToDoEntity save(ToDoEntity entity) { +public ToDoEntity save(ToDoEntity entity) { + System.out.println("Saving ToDoEntity: " + entity); + try { if (entity.getId() == null) { - String sql = "INSERT INTO postgres(title, description, completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?) RETURNING id"; + String sql = "INSERT INTO todos(title, description, completed, created_at, updated_at) VALUES (?, ?, ?, ?, ?) RETURNING id"; Long id = jdbcTemplate.queryForObject(sql, Long.class, entity.getTitle(), entity.getDescription(), entity.isCompleted(), LocalDateTime.now(), LocalDateTime.now()); entity.setId(id); - }else{ - String sql = "UPDATE postgres SET title = ?, description = ?, completed = ?, updated_at = ? WHERE id = ?"; - jdbcTemplate.update(sql, entity.getTitle(), entity.getDescription(), entity.isCompleted(), LocalDateTime.now(), entity.getId()); + System.out.println("Inserted ToDoEntity with id: " + id); + } else { + String sql = "UPDATE todos SET title = ?, description = ?, completed = ?, updated_at = ? WHERE id = ?"; + int rowsAffected = jdbcTemplate.update(sql, entity.getTitle(), entity.getDescription(), entity.isCompleted(), LocalDateTime.now(), entity.getId()); + System.out.println("Updated ToDoEntity with id: " + entity.getId() + ", rows affected: " + rowsAffected); } - return entity; + System.out.println("Saved ToDoEntity: " + entity); + } catch (Exception e) { + System.err.println("Error saving ToDoEntity: " + e.getMessage()); + throw new RuntimeException("Failed to save ToDoEntity", e); } + return entity; +} public Optional findById(Long id){ - String sql = "SELECT * FROM postgres WHERE id = ?"; + String sql = "SELECT * FROM todos WHERE id = ?"; return jdbcTemplate.query(sql, rowMapper, id).stream().findFirst(); } - public List findAll(int limit, int offset){ - String sql = "SELECT * FROM postgres ORDER BY LIMIT ?, OFFSET ?"; - return jdbcTemplate.query(sql, rowMapper, limit, offset); - } +public List findAll(int limit, int offset) { + String sql = "SELECT * FROM todos ORDER BY id LIMIT ? OFFSET ?"; + System.out.println("Executing findAll with limit: " + limit + ", offset: " + offset); + List result = jdbcTemplate.query(sql, rowMapper, limit, offset); + System.out.println("Found " + result.size() + " records"); + return result; +} public void deleteByID(Long id){ - String sql = "DELETE FROM postgres WHERE id = ?"; + String sql = "DELETE FROM todos WHERE id = ?"; jdbcTemplate.update(sql, id); } } diff --git a/src/main/java/com/emobile/springtodo/service/ToDoService.java b/src/main/java/com/emobile/springtodo/service/ToDoService.java index 8ad463f4..a3ce1418 100644 --- a/src/main/java/com/emobile/springtodo/service/ToDoService.java +++ b/src/main/java/com/emobile/springtodo/service/ToDoService.java @@ -8,6 +8,7 @@ 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; @@ -22,28 +23,28 @@ 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) @@ -54,7 +55,7 @@ public ToDoDto update(Long id, ToDoDto dto){ entity = repository.save(entity); return mapper.toDto(entity); } - + @Transactional @CacheEvict(value = "postgres", allEntries = true) public void delete(Long id){ if(!repository.findById(id).isPresent()){ diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties new file mode 100644 index 00000000..7c0f9a08 --- /dev/null +++ b/src/main/resources/application-local.properties @@ -0,0 +1,5 @@ +spring.datasource.url=jdbc:postgresql://localhost:5433/postgres +spring.datasource.username=postgres +spring.datasource.password= +spring.data.redis.host=localhost +spring.data.redis.port=6379 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7a6047ba..4abbb36e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,9 +1,13 @@ spring.application.name=SpringToDo -spring.datasource.url=jdbc:postgresql://localhost:5432/postgres +spring.datasource.url=jdbc:postgresql://localhost:5433/postgres spring.datasource.username=postgres spring.datasource.password= +spring.datasource.driver-class-name=org.postgresql.Driver spring.flyway.enabled=true spring.flyway.locations=classpath:db/migration spring.cache.type=redis spring.data.redis.host=localhost spring.data.redis.port=6379 +#logging.level.org.springframework=DEBUG +#logging.level.com.zaxxer.hikari=DEBUG +#logging.level.org.flywaydb=DEBUG \ No newline at end of file diff --git a/src/main/resources/db/migration/V1__create_postgres_table.sql b/src/main/resources/db/migration/V1__create_postgres_table.sql deleted file mode 100644 index e1c3c675..00000000 --- a/src/main/resources/db/migration/V1__create_postgres_table.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE postgres ( - id BIGSERIAL PRIMARY KEY, - title VARCHAR(100) NOT NULL, - description VARCHAR(500), - completed BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMP NOT NULL, - update_at TIMESTAMP NOT NULL -); \ No newline at end of file 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..0f0fbd66 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_todos_table.sql @@ -0,0 +1,8 @@ +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/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java b/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java index e5476bf8..8286ce95 100644 --- a/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java +++ b/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java @@ -1,85 +1,78 @@ package com.emobile.springtodo.controller; -import com.emobile.springtodo.dto.ToDoDto; -import org.junit.jupiter.api.DisplayName; +import com.emobile.springtodo.SpringToDoApplication; +import org.junit.jupiter.api.BeforeAll; 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.jdbc.Sql; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.http.MediaType; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +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.MockMvcResultMatchers.*; -@SpringBootTest -@AutoConfigureMockMvc @Testcontainers +@SpringBootTest(classes = SpringToDoApplication.class) +@AutoConfigureMockMvc public class ToDoControllerTest { + @Container - static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>("postgres:16.0") + private static final PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16.0") .withDatabaseName("postgres") .withUsername("postgres") - .withPassword("password"); + .withPassword("zenbook14"); @Autowired private MockMvc mockMvc; - private final ObjectMapper objectMapper = new ObjectMapper(); + @BeforeAll + static void setUp() { + System.setProperty("spring.datasource.url", postgresContainer.getJdbcUrl()); + System.setProperty("spring.datasource.username", postgresContainer.getUsername()); + System.setProperty("spring.datasource.password", postgresContainer.getPassword()); + System.setProperty("spring.flyway.enabled", "true"); + } @Test - @DisplayName("Should create a new TODO item") - @Sql("/test-data.sql") - void shouldGetToDOById() throws Exception{ - ToDoDto dto = new ToDoDto(); - dto.setTitle("Test ToDo"); - dto.setDescription("Test Description"); - dto.setCompleted(false); + void shouldGetToDoById() throws Exception { + // Предполагаем, что миграция создала таблицу postgres + String todoJson = "{\"title\":\"Test ToDo\",\"description\":\"Test Description\",\"completed\":false}"; + // Создаём ToDo mockMvc.perform(post("/api/todos") - .contentType(MediaType.APPLICATION_JSON) - .contentType(objectMapper.writeValueAsString(dto))) + .contentType(MediaType.APPLICATION_JSON) + .content(todoJson)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.title").value("Test Todo")) - .andExpect(jsonPath("$.description").value("Test Description")) - .andExpect(jsonPath("$.completed").value(false)); - } + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.title").value("Test ToDo")); - @Test - @DisplayName("Should get TODO item by ID") - @Sql("/test-data.sql") - void shouldGetToDoByID() throws Exception{ - mockMvc.perform(get("/api/todos/1")) + // Проверяем получение по ID + mockMvc.perform(get("/api/todos/1") + .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value("1")) - .andExpect(jsonPath("$.title").value("Sample Todo")); + .andExpect(jsonPath("$.id").value(1)) + .andExpect(jsonPath("$.title").value("Test ToDo")); } @Test - @DisplayName("Should return 404 for non-existent TODO") - void shouldReturn404ForNonExistentToDo() throws Exception{ - mockMvc.perform(get("/api/todos/999")) - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.error").value("ToDo not found with id: 999")); - + void shouldReturn404ForNonExistentToDo() throws Exception { + mockMvc.perform(get("/api/todos/999") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); } @Test - @DisplayName("Should validate ToDo input") - void shouldValidateToDoInput() throws Exception{ - ToDoDto dto = new ToDoDto(); - dto.setTitle(""); + void shouldValidateToDoInput() throws Exception { + String invalidTodoJson = "{\"title\":\"\",\"description\":\"Test Description\",\"completed\":false}"; mockMvc.perform(post("/api/todos") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(dto))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.title").value("Title is mandatory")); + .content(invalidTodoJson)) + .andExpect(status().isBadRequest()); } -} +} \ No newline at end of file diff --git a/src/test/resources/test-data.sql b/src/test/resources/test-data.sql index 3dc949b4..39de44f9 100644 --- a/src/test/resources/test-data.sql +++ b/src/test/resources/test-data.sql @@ -1,2 +1,2 @@ -INSERT INTO todos (id, title, description, completed, created_at, updated_at) +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 From ed56a5eee6c78a4a0eb3734ef94eed5fa9343c0c Mon Sep 17 00:00:00 2001 From: Spire496 Date: Fri, 16 May 2025 00:06:04 +0500 Subject: [PATCH 3/3] Commit #3 fixed --- pom.xml | 13 +- .../springtodo/config/RedisConfig.java | 4 +- .../emobile/springtodo/mapper/ToDoMapper.java | 1 + .../resources/application-local.properties | 2 +- .../resources/application-test.properties | 9 ++ src/main/resources/application.properties | 2 +- .../db/migration/V1__create_todos_table.sql | 17 ++- .../controller/ToDoControllerTest.java | 144 +++++++++++++++--- src/test/resources/insert-multiple-todos.sql | 4 + src/test/resources/insert-todo.sql | 2 + 10 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 src/main/resources/application-test.properties create mode 100644 src/test/resources/insert-multiple-todos.sql create mode 100644 src/test/resources/insert-todo.sql diff --git a/pom.xml b/pom.xml index af01b766..4a4785e6 100644 --- a/pom.xml +++ b/pom.xml @@ -47,7 +47,18 @@ org.flywaydb flyway-core - 9.22.3 + 10.17.1 + + + org.flywaydb + flyway-database-postgresql + 10.17.1 + + + org.skyscreamer + jsonassert + 1.5.3 + test diff --git a/src/main/java/com/emobile/springtodo/config/RedisConfig.java b/src/main/java/com/emobile/springtodo/config/RedisConfig.java index b2273b46..a04a50ce 100644 --- a/src/main/java/com/emobile/springtodo/config/RedisConfig.java +++ b/src/main/java/com/emobile/springtodo/config/RedisConfig.java @@ -1,5 +1,7 @@ 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.data.redis.cache.RedisCacheConfiguration; @@ -15,4 +17,4 @@ public RedisCacheConfiguration cacheConfiguration() { .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())); } -} +} \ No newline at end of file diff --git a/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java index 74e23321..03437f7b 100644 --- a/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java +++ b/src/main/java/com/emobile/springtodo/mapper/ToDoMapper.java @@ -7,6 +7,7 @@ @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); diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 7c0f9a08..3e917d84 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -1,5 +1,5 @@ spring.datasource.url=jdbc:postgresql://localhost:5433/postgres spring.datasource.username=postgres -spring.datasource.password= +spring.datasource.password=zenbook14 spring.data.redis.host=localhost spring.data.redis.port=6379 \ No newline at end of file diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties new file mode 100644 index 00000000..dfafeb77 --- /dev/null +++ b/src/main/resources/application-test.properties @@ -0,0 +1,9 @@ +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.cache.type=redis +spring.data.redis.host=localhost +spring.data.redis.port=6379 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4abbb36e..dfde6cfc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,7 @@ spring.application.name=SpringToDo spring.datasource.url=jdbc:postgresql://localhost:5433/postgres spring.datasource.username=postgres -spring.datasource.password= +spring.datasource.password=zenbook14 spring.datasource.driver-class-name=org.postgresql.Driver spring.flyway.enabled=true spring.flyway.locations=classpath:db/migration diff --git a/src/main/resources/db/migration/V1__create_todos_table.sql b/src/main/resources/db/migration/V1__create_todos_table.sql index 0f0fbd66..25a019c5 100644 --- a/src/main/resources/db/migration/V1__create_todos_table.sql +++ b/src/main/resources/db/migration/V1__create_todos_table.sql @@ -1,8 +1,11 @@ -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 +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/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java b/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java index 8286ce95..406616b8 100644 --- a/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java +++ b/src/test/java/com/emobile/springtodo/controller/ToDoControllerTest.java @@ -1,28 +1,36 @@ package com.emobile.springtodo.controller; import com.emobile.springtodo.SpringToDoApplication; -import org.junit.jupiter.api.BeforeAll; +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.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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.0") + private static final PostgreSQLContainer postgresContainer = new PostgreSQLContainer<>("postgres:16.8") .withDatabaseName("postgres") .withUsername("postgres") .withPassword("zenbook14"); @@ -30,49 +38,137 @@ public class ToDoControllerTest { @Autowired private MockMvc mockMvc; - @BeforeAll - static void setUp() { - System.setProperty("spring.datasource.url", postgresContainer.getJdbcUrl()); - System.setProperty("spring.datasource.username", postgresContainer.getUsername()); - System.setProperty("spring.datasource.password", postgresContainer.getPassword()); - System.setProperty("spring.flyway.enabled", "true"); + @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 - void shouldGetToDoById() throws Exception { - // Предполагаем, что миграция создала таблицу postgres + @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 - mockMvc.perform(post("/api/todos") + MvcResult createResult = mockMvc.perform(post("/api/todos") .contentType(MediaType.APPLICATION_JSON) .content(todoJson)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.title").value("Test ToDo")); + .andReturn(); + + JSONAssert.assertEquals(expectedJson, createResult.getResponse().getContentAsString(), false); // Проверяем получение по ID - mockMvc.perform(get("/api/todos/1") + MvcResult getResult = mockMvc.perform(get("/api/todos/1") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(1)) - .andExpect(jsonPath("$.title").value("Test ToDo")); + .andReturn(); + + JSONAssert.assertEquals(expectedJson, getResult.getResponse().getContentAsString(), false); } @Test + @DisplayName("Should return 404 for non-existent ToDo") void shouldReturn404ForNonExistentToDo() throws Exception { - mockMvc.perform(get("/api/todos/999") + String expectedErrorJson = "{\"error\":\"ToDo not found with id: 999\"}"; + + MvcResult result = mockMvc.perform(get("/api/todos/999") .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isNotFound()); + .andExpect(status().isNotFound()) + .andReturn(); + + JSONAssert.assertEquals(expectedErrorJson, result.getResponse().getContentAsString(), false); } @Test void shouldValidateToDoInput() throws Exception { - String invalidTodoJson = "{\"title\":\"\",\"description\":\"Test Description\",\"completed\":false}"; + 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(invalidTodoJson)) - .andExpect(status().isBadRequest()); + .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); } -} \ No newline at end of file +} 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