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..4a4785e6 100644 --- a/pom.xml +++ b/pom.xml @@ -1,34 +1,109 @@ - 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.6.2 + org.springframework.boot - spring-boot-starter + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-jdbc + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.flywaydb + flyway-core + 10.17.1 - + + org.flywaydb + flyway-database-postgresql + 10.17.1 + + + org.skyscreamer + jsonassert + 1.5.3 + test + + + + org.postgresql + postgresql + 42.7.4 + + org.springframework.boot spring-boot-starter-test test + + + org.testcontainers + postgresql + 1.20.2 + test + + + + org.testcontainers + junit-jupiter + 1.20.2 + test + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided + @@ -37,7 +112,22 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 21 + 21 + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + - - + \ 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/config/RedisConfig.java b/src/main/java/com/emobile/springtodo/config/RedisConfig.java new file mode 100644 index 00000000..a04a50ce --- /dev/null +++ b/src/main/java/com/emobile/springtodo/config/RedisConfig.java @@ -0,0 +1,20 @@ +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; +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())); + } +} \ 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..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..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/ToDoRepository.java b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java new file mode 100644 index 00000000..c07f5654 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/repository/ToDoRepository.java @@ -0,0 +1,71 @@ +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) { + System.out.println("Saving ToDoEntity: " + entity); + try { + if (entity.getId() == null) { + 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); + 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); + } + 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 todos WHERE id = ?"; + return jdbcTemplate.query(sql, rowMapper, id).stream().findFirst(); + } + +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 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 new file mode 100644 index 00000000..a3ce1418 --- /dev/null +++ b/src/main/java/com/emobile/springtodo/service/ToDoService.java @@ -0,0 +1,66 @@ +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.getCompleted()); + 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); + } +} diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties new file mode 100644 index 00000000..3e917d84 --- /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=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 5c3e5461..dfde6cfc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,13 @@ spring.application.name=SpringToDo +spring.datasource.url=jdbc:postgresql://localhost:5433/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 +#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_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/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