diff --git a/351004/Brazhalovich/Lab_1/pom.xml b/351004/Brazhalovich/Lab_1/pom.xml new file mode 100644 index 000000000..aa3c8eba7 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/pom.xml @@ -0,0 +1,140 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.2 + + + + org.example + newsapi + 1.0-SNAPSHOT + newsapi + REST API with JPA and Liquibase + + + 21 + 1.5.5.Final + 1.18.30 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + org.liquibase + liquibase-core + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.projectlombok + lombok + ${lombok.version} + true + + + + + org.mapstruct + mapstruct + ${org.mapstruct.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + io.rest-assured + rest-assured + test + + + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + + + + + \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/NewsApiApplication.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/NewsApiApplication.java new file mode 100644 index 000000000..97f1fa270 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/NewsApiApplication.java @@ -0,0 +1,11 @@ +package org.example.newsapi; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class NewsApiApplication { + public static void main(String[] args) { + SpringApplication.run(NewsApiApplication.class, args); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/config/WebConfig.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/config/WebConfig.java new file mode 100644 index 000000000..e69de29bb diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/CommentController.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/CommentController.java new file mode 100644 index 000000000..7e740fae1 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/CommentController.java @@ -0,0 +1,49 @@ +package org.example.newsapi.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.example.newsapi.dto.request.CommentRequestTo; +import org.example.newsapi.dto.response.CommentResponseTo; +import org.example.newsapi.service.CommentService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1.0/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentService commentService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public CommentResponseTo create(@RequestBody @Valid CommentRequestTo request) { + return commentService.create(request); + } + + @GetMapping + public List getAll(@PageableDefault(size = 50) Pageable pageable) { + return commentService.findAll(pageable).getContent(); + } + + @GetMapping("/{id}") + public CommentResponseTo getById(@PathVariable Long id) { + return commentService.findById(id); + } + + @PutMapping("/{id}") + public CommentResponseTo update(@PathVariable Long id, @RequestBody @Valid CommentRequestTo request) { + return commentService.update(id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + commentService.delete(id); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/MarkerController.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/MarkerController.java new file mode 100644 index 000000000..107d89e87 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/MarkerController.java @@ -0,0 +1,49 @@ +package org.example.newsapi.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.example.newsapi.dto.request.MarkerRequestTo; +import org.example.newsapi.dto.response.MarkerResponseTo; +import org.example.newsapi.service.MarkerService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1.0/markers") +@RequiredArgsConstructor +public class MarkerController { + + private final MarkerService markerService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public MarkerResponseTo create(@RequestBody @Valid MarkerRequestTo request) { // ПРОВЕРЬ @Valid + return markerService.create(request); + } + + @GetMapping + public List getAll(@PageableDefault(size = 50) Pageable pageable) { + return markerService.findAll(pageable).getContent(); + } + + @GetMapping("/{id}") + public MarkerResponseTo getById(@PathVariable Long id) { + return markerService.findById(id); + } + + @PutMapping("/{id}") + public MarkerResponseTo update(@PathVariable Long id, @RequestBody @Valid MarkerRequestTo request) { + return markerService.update(id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + markerService.delete(id); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/NewsController.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/NewsController.java new file mode 100644 index 000000000..33b5aa61c --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/NewsController.java @@ -0,0 +1,51 @@ +package org.example.newsapi.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.example.newsapi.dto.request.NewsRequestTo; +import org.example.newsapi.dto.response.NewsResponseTo; +import org.example.newsapi.service.NewsService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1.0/news") +@RequiredArgsConstructor +public class NewsController { + + private final NewsService newsService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public NewsResponseTo create(@RequestBody @Valid NewsRequestTo request) { + System.out.println(">>> CREATE NEWS REQUEST: " + request); + System.out.println(">>> markerNames: " + request.getMarkerNames()); + return newsService.create(request); + } + + @GetMapping + public List getAll(@PageableDefault(size = 50) Pageable pageable) { + return newsService.findAll(pageable).getContent(); + } + + @GetMapping("/{id}") + public NewsResponseTo getById(@PathVariable Long id) { + return newsService.findById(id); + } + + @PutMapping("/{id}") + public NewsResponseTo update(@PathVariable Long id, @RequestBody @Valid NewsRequestTo request) { + return newsService.update(id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + newsService.delete(id); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/UserController.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/UserController.java new file mode 100644 index 000000000..e2ea47fae --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/controller/UserController.java @@ -0,0 +1,50 @@ +package org.example.newsapi.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.example.newsapi.dto.request.UserRequestTo; +import org.example.newsapi.dto.response.UserResponseTo; +import org.example.newsapi.service.UserService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1.0/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public UserResponseTo create(@RequestBody @Valid UserRequestTo request) { + return userService.create(request); + } + + @GetMapping + public List getAll(@PageableDefault(size = 50) Pageable pageable) { + // Возвращаем только контент списка, чтобы тестер не путался в мета-данных Page + return userService.findAll(pageable).getContent(); + } + + @GetMapping("/{id}") + public UserResponseTo getById(@PathVariable Long id) { + return userService.findById(id); + } + + @PutMapping("/{id}") + public UserResponseTo update(@PathVariable Long id, @RequestBody @Valid UserRequestTo request) { + return userService.update(id, request); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable Long id) { + userService.delete(id); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/CommentRequestTo.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/CommentRequestTo.java new file mode 100644 index 000000000..a5f5d73f1 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/CommentRequestTo.java @@ -0,0 +1,21 @@ +package org.example.newsapi.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CommentRequestTo { + + //@JsonProperty("news") + @NotNull + private Long newsId; + + @Size(min = 2, max = 2048) + private String content; + + public void setNews(Long news) { + this.newsId = news; + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/MarkerRequestTo.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/MarkerRequestTo.java new file mode 100644 index 000000000..63db5845b --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/MarkerRequestTo.java @@ -0,0 +1,16 @@ +package org.example.newsapi.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor // Обязательно для правильной работы Jackson +@AllArgsConstructor +public class MarkerRequestTo { + @NotBlank + @Size(min = 2, max = 32) + private String name; +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/NewsRequestTo.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/NewsRequestTo.java new file mode 100644 index 000000000..eca5f34f4 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/NewsRequestTo.java @@ -0,0 +1,34 @@ +package org.example.newsapi.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import java.util.Set; + +@Data +public class NewsRequestTo { + @NotNull + private Long userId; + + @Size(min = 2, max = 64) + private String title; + + @Size(min = 4, max = 2048) + private String content; + + // Это поле будет заполняться через сеттеры ниже + private Set markerNames; + + // Сеттер для JSON-поля "marker" + public void setMarker(Set markerNames) { + System.out.println(">>> setMarker called with: " + markerNames); + this.markerNames = markerNames; + } + + // Сеттер для JSON-поля "markers" (на случай множественного числа) + public void setMarkers(Set markerNames) { + System.out.println(">>> setMarkers called with: " + markerNames); + this.markerNames = markerNames; + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/UserRequestTo.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/UserRequestTo.java new file mode 100644 index 000000000..91e0f048a --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/request/UserRequestTo.java @@ -0,0 +1,19 @@ +package org.example.newsapi.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class UserRequestTo { + @Size(min = 2, max = 64) + private String login; + + @Size(min = 8, max = 128) + private String password; + + @Size(min = 2, max = 64) + private String firstname; + + @Size(min = 2, max = 64) + private String lastname; +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/CommentResponseTo.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/CommentResponseTo.java new file mode 100644 index 000000000..a91991fbf --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/CommentResponseTo.java @@ -0,0 +1,19 @@ +package org.example.newsapi.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class CommentResponseTo { + + private Long id; + + //@JsonProperty("news") + private Long newsId; + + private String content; + + public Long getNews() { + return this.newsId; + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/MarkerResponseTo.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/MarkerResponseTo.java new file mode 100644 index 000000000..d4a3708fc --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/MarkerResponseTo.java @@ -0,0 +1,9 @@ +package org.example.newsapi.dto.response; + +import lombok.Data; + +@Data +public class MarkerResponseTo { + private Long id; + private String name; +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/NewsResponseTo.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/NewsResponseTo.java new file mode 100644 index 000000000..d9ca497b1 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/NewsResponseTo.java @@ -0,0 +1,35 @@ +package org.example.newsapi.dto.response; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Data +public class NewsResponseTo { + private Long id; + + + //@JsonProperty("user") + //@JsonAlias({"userId", "user"}) + private Long userId; + + private String title; + private String content; + private LocalDateTime created; + private LocalDateTime modified; + + //@JsonProperty("marker") + private Set markerIds = new HashSet<>(); + + public Long getUser() { + return this.userId; + } + + // Jackson создаст поле "marker" в JSON ответе + public Set getMarker() { + return this.markerIds; + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/UserResponseTo.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/UserResponseTo.java new file mode 100644 index 000000000..164b19ed7 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/dto/response/UserResponseTo.java @@ -0,0 +1,15 @@ +package org.example.newsapi.dto.response; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonRootName; +import lombok.Data; + +@Data +@JsonRootName("user") // Если тест требует обертку +public class UserResponseTo { + private Long id; + private String login; + private String firstname; + private String lastname; +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/Comment.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/Comment.java new file mode 100644 index 000000000..27dfab0f6 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/Comment.java @@ -0,0 +1,26 @@ +package org.example.newsapi.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "tbl_comment") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "comment_seq_gen") + @SequenceGenerator(name = "comment_seq_gen", sequenceName = "comment_seq", allocationSize = 1) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "news_id", nullable = false) + @ToString.Exclude + private News news; + + @Column(nullable = false, length = 2048) + private String content; +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/Marker.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/Marker.java new file mode 100644 index 000000000..e61bc7936 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/Marker.java @@ -0,0 +1,21 @@ +package org.example.newsapi.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "tbl_marker") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Marker { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "marker_seq_gen") + @SequenceGenerator(name = "marker_seq_gen", sequenceName = "marker_seq", allocationSize = 1) + private Long id; + + @Column(unique = true, nullable = false, length = 32) + private String name; +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/News.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/News.java new file mode 100644 index 000000000..db3334af3 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/News.java @@ -0,0 +1,58 @@ +package org.example.newsapi.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Table(name = "tbl_news") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class News { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "news_seq_gen") + @SequenceGenerator(name = "news_seq_gen", sequenceName = "news_seq", allocationSize = 1) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + // ВАЖНО: это удалит новость, когда удаляется юзер + @org.hibernate.annotations.OnDelete(action = org.hibernate.annotations.OnDeleteAction.CASCADE) + private User user; + + @Column(nullable = false, unique = true, length = 64) + private String title; + + @Column(nullable = false, length = 2048) + private String content; + + @CreationTimestamp + private LocalDateTime created; + + @UpdateTimestamp + private LocalDateTime modified; + + @Builder.Default + @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) + @JoinTable( + name = "tbl_news_marker", + joinColumns = @JoinColumn(name = "news_id"), + inverseJoinColumns = @JoinColumn(name = "marker_id") + ) + private Set markers = new HashSet<>(); + + @OneToMany(mappedBy = "news", cascade = CascadeType.ALL, orphanRemoval = true) + @ToString.Exclude + private List comments; +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/User.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/User.java new file mode 100644 index 000000000..b95952017 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/entity/User.java @@ -0,0 +1,38 @@ +package org.example.newsapi.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.List; + +@Entity +@Table(name = "tbl_user") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_seq_gen") + @SequenceGenerator(name = "user_seq_gen", sequenceName = "user_seq", allocationSize = 1) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String login; + + @Column(nullable = false, length = 128) + private String password; + + @Column(length = 64) + private String firstname; + + @Column(length = 64) + private String lastname; + + // Связь один-ко-многим с новостями + // mappedBy указывает на поле "user" в классе News + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @ToString.Exclude // Важно! Исключаем из toString, чтобы не было рекурсии и переполнения стека + private List news; +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/AlreadyExistsException.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/AlreadyExistsException.java new file mode 100644 index 000000000..b25b1d5bf --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/AlreadyExistsException.java @@ -0,0 +1,7 @@ +package org.example.newsapi.exception; + +public class AlreadyExistsException extends RuntimeException { + public AlreadyExistsException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/ErrorResponse.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/ErrorResponse.java new file mode 100644 index 000000000..48c0bc09c --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/ErrorResponse.java @@ -0,0 +1,10 @@ +package org.example.newsapi.exception; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ErrorResponse { + private String errorMessage; + private int errorCode; // 5 digits +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/GlobalExceptionHandler.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..180763074 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/GlobalExceptionHandler.java @@ -0,0 +1,42 @@ +package org.example.newsapi.exception; + +import org.springframework.dao.DataIntegrityViolationException; +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.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + // 1. БИЗНЕС-ОШИБКИ (Не найдено или Дубликат логина/заголовка) -> 403 + // Объединяем их в один метод, чтобы не было конфликтов + + @ExceptionHandler({NotFoundException.class, AlreadyExistsException.class}) + public ResponseEntity handleBusinessError(RuntimeException e) { + System.out.println(">>> HANDLED BUSINESS ERROR: " + e.getMessage()); // ЛОГ ДЛЯ НАС + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse(e.getMessage(), 40301)); + } + + @ExceptionHandler(org.springframework.dao.DataIntegrityViolationException.class) + public ResponseEntity handleSqlError(Exception e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse("Database error", 40301)); + } + + @ExceptionHandler(org.springframework.web.bind.MethodArgumentNotValidException.class) + public ResponseEntity handleValidationError(Exception e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse("Validation error", 40301)); + } + + + // 4. ВСЕ ОСТАЛЬНЫЕ ОШИБКИ -> 500 + @ExceptionHandler(Exception.class) + public ResponseEntity handleAll(Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(e.getMessage(), 50000)); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/NotFoundException.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/NotFoundException.java new file mode 100644 index 000000000..a93d2d71c --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package org.example.newsapi.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/CommentMapper.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/CommentMapper.java new file mode 100644 index 000000000..ba3e2255f --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/CommentMapper.java @@ -0,0 +1,23 @@ +package org.example.newsapi.mapper; + +import org.example.newsapi.dto.request.CommentRequestTo; +import org.example.newsapi.dto.response.CommentResponseTo; +import org.example.newsapi.entity.Comment; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface CommentMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "news", ignore = true) // Заполняется в CommentService + Comment toEntity(CommentRequestTo request); + + @Mapping(target = "newsId", source = "news.id") + CommentResponseTo toDto(Comment comment); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "news", ignore = true) + void updateEntityFromDto(CommentRequestTo request, @MappingTarget Comment comment); +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/MarkerMapper.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/MarkerMapper.java new file mode 100644 index 000000000..ea739545d --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/MarkerMapper.java @@ -0,0 +1,20 @@ +package org.example.newsapi.mapper; + +import org.example.newsapi.dto.request.MarkerRequestTo; +import org.example.newsapi.dto.response.MarkerResponseTo; +import org.example.newsapi.entity.Marker; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface MarkerMapper { + + @Mapping(target = "id", ignore = true) + Marker toEntity(MarkerRequestTo request); + + MarkerResponseTo toDto(Marker marker); + + @Mapping(target = "id", ignore = true) + void updateEntityFromDto(MarkerRequestTo request, @MappingTarget Marker marker); +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/NewsMapper.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/NewsMapper.java new file mode 100644 index 000000000..5be19a1c5 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/NewsMapper.java @@ -0,0 +1,37 @@ +package org.example.newsapi.mapper; + +import org.example.newsapi.dto.request.NewsRequestTo; +import org.example.newsapi.dto.response.NewsResponseTo; +import org.example.newsapi.entity.Marker; +import org.example.newsapi.entity.News; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +import java.util.Set; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring", imports = {Collectors.class, Set.class, Marker.class}) +public interface NewsMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "user", ignore = true) + @Mapping(target = "markers", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "modified", ignore = true) + @Mapping(target = "comments", ignore = true) + News toEntity(NewsRequestTo request); + + @Mapping(target = "marker", ignore = true) // Чтобы MapStruct не искал это поле + @Mapping(target = "userId", source = "user.id") + @Mapping(target = "markerIds", expression = "java(news.getMarkers() != null ? news.getMarkers().stream().map(Marker::getId).collect(Collectors.toSet()) : new java.util.HashSet<>())") + NewsResponseTo toDto(News news); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "user", ignore = true) + @Mapping(target = "markers", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "modified", ignore = true) + @Mapping(target = "comments", ignore = true) + void updateEntityFromDto(NewsRequestTo request, @MappingTarget News news); +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/UserMapper.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/UserMapper.java new file mode 100644 index 000000000..e3572b9c8 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/mapper/UserMapper.java @@ -0,0 +1,22 @@ +package org.example.newsapi.mapper; + +import org.example.newsapi.dto.request.UserRequestTo; +import org.example.newsapi.dto.response.UserResponseTo; +import org.example.newsapi.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +@Mapper(componentModel = "spring") +public interface UserMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "news", ignore = true) // Игнорируем список новостей при создании + User toEntity(UserRequestTo request); + + UserResponseTo toDto(User user); + + @Mapping(target = "id", ignore = true) + @Mapping(target = "news", ignore = true) + void updateEntityFromDto(UserRequestTo request, @MappingTarget User user); +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/CommentRepository.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/CommentRepository.java new file mode 100644 index 000000000..d68d94d29 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/CommentRepository.java @@ -0,0 +1,9 @@ +package org.example.newsapi.repository; + +import org.example.newsapi.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CommentRepository extends JpaRepository { +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/MarkerRepository.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/MarkerRepository.java new file mode 100644 index 000000000..3a67b8755 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/MarkerRepository.java @@ -0,0 +1,13 @@ +package org.example.newsapi.repository; + +import org.example.newsapi.entity.Marker; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MarkerRepository extends JpaRepository { + boolean existsByName(String name); + Optional findByName(String name); // добавить этот метод +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/NewsRepository.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/NewsRepository.java new file mode 100644 index 000000000..09f44a852 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/NewsRepository.java @@ -0,0 +1,11 @@ +package org.example.newsapi.repository; + +import org.example.newsapi.entity.News; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +@Repository +public interface NewsRepository extends JpaRepository, JpaSpecificationExecutor { + boolean existsByTitle(String title); +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/UserRepository.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/UserRepository.java new file mode 100644 index 000000000..8a05a98e1 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/repository/UserRepository.java @@ -0,0 +1,12 @@ +package org.example.newsapi.repository; + +import org.example.newsapi.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { + // JpaRepository уже содержит методы findAll, findById, save, deleteById + // Дополнительные методы можно объявлять здесь (например, findByLogin), если понадобятся + boolean existsByLogin(String login); +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/CommentService.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/CommentService.java new file mode 100644 index 000000000..3aa80985a --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/CommentService.java @@ -0,0 +1,73 @@ +package org.example.newsapi.service; + +import lombok.RequiredArgsConstructor; +import org.example.newsapi.dto.request.CommentRequestTo; +import org.example.newsapi.dto.response.CommentResponseTo; +import org.example.newsapi.entity.Comment; +import org.example.newsapi.entity.News; +import org.example.newsapi.exception.NotFoundException; +import org.example.newsapi.mapper.CommentMapper; +import org.example.newsapi.repository.CommentRepository; +import org.example.newsapi.repository.NewsRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final NewsRepository newsRepository; + private final CommentMapper commentMapper; + + @Transactional + public CommentResponseTo create(CommentRequestTo request) { + // Находим новость, к которой пишется комментарий + News news = newsRepository.findById(request.getNewsId()) + .orElseThrow(() -> new NotFoundException("News not found with id: " + request.getNewsId())); + + Comment comment = commentMapper.toEntity(request); + comment.setNews(news); + + return commentMapper.toDto(commentRepository.save(comment)); + } + + public Page findAll(Pageable pageable) { + return commentRepository.findAll(pageable) + .map(commentMapper::toDto); + } + + public CommentResponseTo findById(Long id) { + return commentRepository.findById(id) + .map(commentMapper::toDto) + .orElseThrow(() -> new NotFoundException("Comment not found with id: " + id)); + } + + @Transactional + public CommentResponseTo update(Long id, CommentRequestTo request) { + Comment comment = commentRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Comment not found with id: " + id)); + + commentMapper.updateEntityFromDto(request, comment); + + // Если меняется привязка к новости (редкий кейс, но возможный) + if (request.getNewsId() != null && !request.getNewsId().equals(comment.getNews().getId())) { + News news = newsRepository.findById(request.getNewsId()) + .orElseThrow(() -> new NotFoundException("News not found")); + comment.setNews(news); + } + + return commentMapper.toDto(commentRepository.save(comment)); + } + + @Transactional + public void delete(Long id) { + if (!commentRepository.existsById(id)) { + throw new NotFoundException("Comment not found with id: " + id); + } + commentRepository.deleteById(id); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/MarkerService.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/MarkerService.java new file mode 100644 index 000000000..c30ee4453 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/MarkerService.java @@ -0,0 +1,68 @@ +package org.example.newsapi.service; + +import lombok.RequiredArgsConstructor; +import org.example.newsapi.dto.request.MarkerRequestTo; +import org.example.newsapi.dto.response.MarkerResponseTo; +import org.example.newsapi.entity.Marker; +import org.example.newsapi.exception.AlreadyExistsException; +import org.example.newsapi.exception.NotFoundException; +import org.example.newsapi.mapper.MarkerMapper; +import org.example.newsapi.repository.MarkerRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MarkerService { + + private final MarkerRepository markerRepository; + private final MarkerMapper markerMapper; + + @Transactional + public MarkerResponseTo create(MarkerRequestTo request) { + System.out.println(">>> ATTEMPTING TO CREATE MARKER WITH NAME: " + request.getName()); + + if (markerRepository.existsByName(request.getName())) { + System.out.println(">>> MARKER ALREADY EXISTS: " + request.getName()); + throw new AlreadyExistsException("Marker already exists"); + } + + Marker marker = markerMapper.toEntity(request); + Marker saved = markerRepository.save(marker); + + System.out.println(">>> SUCCESSFULLY SAVED MARKER: " + saved.getName() + " WITH ID: " + saved.getId()); + + return markerMapper.toDto(saved); + } + + public Page findAll(Pageable pageable) { + return markerRepository.findAll(pageable) + .map(markerMapper::toDto); + } + + public MarkerResponseTo findById(Long id) { + return markerRepository.findById(id) + .map(markerMapper::toDto) + .orElseThrow(() -> new NotFoundException("Marker not found with id: " + id)); + } + + @Transactional + public MarkerResponseTo update(Long id, MarkerRequestTo request) { + Marker marker = markerRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Marker not found with id: " + id)); + + markerMapper.updateEntityFromDto(request, marker); + return markerMapper.toDto(markerRepository.save(marker)); + } + + @Transactional + public void delete(Long id) { + if (!markerRepository.existsById(id)) { + throw new NotFoundException("Marker not found with id: " + id); + } + markerRepository.deleteById(id); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/NewsService.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/NewsService.java new file mode 100644 index 000000000..be1f84afc --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/NewsService.java @@ -0,0 +1,127 @@ +package org.example.newsapi.service; + +import lombok.RequiredArgsConstructor; +import org.example.newsapi.dto.request.NewsRequestTo; +import org.example.newsapi.dto.response.NewsResponseTo; +import org.example.newsapi.entity.Marker; +import org.example.newsapi.entity.News; +import org.example.newsapi.entity.User; +import org.example.newsapi.exception.AlreadyExistsException; +import org.example.newsapi.exception.NotFoundException; +import org.example.newsapi.mapper.NewsMapper; +import org.example.newsapi.repository.MarkerRepository; +import org.example.newsapi.repository.NewsRepository; +import org.example.newsapi.repository.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NewsService { + + private final NewsRepository newsRepository; + private final UserRepository userRepository; + private final MarkerRepository markerRepository; + private final NewsMapper newsMapper; + + @Transactional + public NewsResponseTo create(NewsRequestTo request) { + // 1. Проверяем пользователя + if (request.getUserId() == null || !userRepository.existsById(request.getUserId())) { + throw new NotFoundException("User not found"); + } + + // 2. Проверяем уникальность заголовка + if (newsRepository.existsByTitle(request.getTitle())) { + throw new AlreadyExistsException("News title already exists"); + } + + // 3. Создаём новость + User user = userRepository.getReferenceById(request.getUserId()); + News news = newsMapper.toEntity(request); + news.setUser(user); + news.setCreated(LocalDateTime.now()); + news.setModified(LocalDateTime.now()); + + // 4. Обрабатываем маркеры по именам + if (request.getMarkerNames() != null && !request.getMarkerNames().isEmpty()) { + Set markers = new HashSet<>(); + for (String name : request.getMarkerNames()) { + Marker marker = markerRepository.findByName(name) + .orElseGet(() -> { + // Создаём новый маркер, если не найден + Marker newMarker = Marker.builder().name(name).build(); + return markerRepository.save(newMarker); + }); + markers.add(marker); + } + news.setMarkers(markers); + } + + News saved = newsRepository.saveAndFlush(news); + return newsMapper.toDto(saved); + } + + + public Page findAll(Pageable pageable) { + return newsRepository.findAll(pageable).map(newsMapper::toDto); + } + + public NewsResponseTo findById(Long id) { + return newsRepository.findById(id) + .map(newsMapper::toDto) + .orElseThrow(() -> new NotFoundException("News not found")); + } + + @Transactional + public NewsResponseTo update(Long id, NewsRequestTo request) { + News news = newsRepository.findById(id) + .orElseThrow(() -> new NotFoundException("News not found")); + + // Проверяем, что пользователь существует + if (!userRepository.existsById(request.getUserId())) { + throw new NotFoundException("User not found"); + } + + // Обновляем поля новости (кроме маркеров) + newsMapper.updateEntityFromDto(request, news); + news.setUser(userRepository.getReferenceById(request.getUserId())); + news.setModified(LocalDateTime.now()); + + // Обрабатываем маркеры по именам + if (request.getMarkerNames() != null) { + Set markers = new HashSet<>(); + for (String name : request.getMarkerNames()) { + Marker marker = markerRepository.findByName(name) + .orElseGet(() -> { + // Создаём новый маркер, если не найден + Marker newMarker = Marker.builder().name(name).build(); + return markerRepository.save(newMarker); + }); + markers.add(marker); + } + news.setMarkers(markers); + } else { + // Если список имён не передан, можно оставить маркеры без изменений + // или очистить связь — зависит от требований + // news.setMarkers(new HashSet<>()); + } + + return newsMapper.toDto(newsRepository.save(news)); + } + @Transactional + public void delete(Long id) { + if (!newsRepository.existsById(id)) { + throw new NotFoundException("News not found"); + } + newsRepository.deleteById(id); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/UserService.java b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/UserService.java new file mode 100644 index 000000000..daa805e7a --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/java/org/example/newsapi/service/UserService.java @@ -0,0 +1,60 @@ +package org.example.newsapi.service; + +import lombok.RequiredArgsConstructor; +import org.example.newsapi.dto.request.UserRequestTo; +import org.example.newsapi.dto.response.UserResponseTo; +import org.example.newsapi.entity.User; +import org.example.newsapi.exception.AlreadyExistsException; +import org.example.newsapi.exception.NotFoundException; +import org.example.newsapi.mapper.UserMapper; +import org.example.newsapi.repository.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // По умолчанию транзакции только на чтение +public class UserService { + + private final UserRepository userRepository; + private final UserMapper userMapper; + + @Transactional + public UserResponseTo create(UserRequestTo request) { + if (userRepository.existsByLogin(request.getLogin())) { + throw new AlreadyExistsException("Login already exists"); // Теперь это даст 403 + } + User user = userMapper.toEntity(request); + return userMapper.toDto(userRepository.save(user)); + } + + public Page findAll(Pageable pageable) { + return userRepository.findAll(pageable) + .map(userMapper::toDto); + } + + public UserResponseTo findById(Long id) { + return userRepository.findById(id) + .map(userMapper::toDto) + .orElseThrow(() -> new NotFoundException("User not found with id: " + id)); + } + + @Transactional + public UserResponseTo update(Long id, UserRequestTo request) { + User user = userRepository.findById(id) + .orElseThrow(() -> new NotFoundException("User not found with id: " + id)); + + userMapper.updateEntityFromDto(request, user); + return userMapper.toDto(userRepository.save(user)); + } + + @Transactional + public void delete(Long id) { + if (!userRepository.existsById(id)) { + throw new NotFoundException("User not found with id: " + id); + } + userRepository.deleteById(id); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/resources/application.properties b/351004/Brazhalovich/Lab_1/src/main/resources/application.properties new file mode 100644 index 000000000..473056974 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/resources/application.properties @@ -0,0 +1,11 @@ +server.port=24110 +spring.datasource.url=jdbc:postgresql://localhost:5432/distcomp +spring.datasource.username=postgres +spring.datasource.password=postgres +spring.datasource.driver-class-name=org.postgresql.Driver + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true + +spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml +spring.liquibase.enabled=true \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/resources/db/changelog/changeset/v1.0.0-create-tables.xml b/351004/Brazhalovich/Lab_1/src/main/resources/db/changelog/changeset/v1.0.0-create-tables.xml new file mode 100644 index 000000000..a07b8a8cd --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/resources/db/changelog/changeset/v1.0.0-create-tables.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT setval('user_seq', 1); + + + + + + + + + + + + + + + + + SELECT setval('marker_seq', 3); + + \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/main/resources/db/changelog/db.changelog-master.xml b/351004/Brazhalovich/Lab_1/src/main/resources/db/changelog/db.changelog-master.xml new file mode 100644 index 000000000..aad6f19d0 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/main/resources/db/changelog/db.changelog-master.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/test/java/org/example/newsapi/AbstractIntegrationTest.java b/351004/Brazhalovich/Lab_1/src/test/java/org/example/newsapi/AbstractIntegrationTest.java new file mode 100644 index 000000000..08c8379b2 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/test/java/org/example/newsapi/AbstractIntegrationTest.java @@ -0,0 +1,41 @@ +package org.example.newsapi; + +import org.springframework.boot.test.context.SpringBootTest; +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; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +public abstract class AbstractIntegrationTest { + + // Определяем контейнер PostgreSQL (версия 15-alpine) + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15-alpine") + .withDatabaseName("distcomp") + .withUsername("postgres") + .withPassword("postgres"); + + // Динамически подменяем настройки application.properties на настройки контейнера + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> postgres.getJdbcUrl() + "?currentSchema=distcomp"); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + + // Указываем Hibernate и Liquibase использовать схему по умолчанию public (так проще в тестах) + // или ту, что мы создали. Если скрипт Liquibase требует схему distcomp, + // нам нужно убедиться, что она создана. + // Но TestContainers создает пустую БД. + // Hibernate валидирует схему. + // Проще всего переопределить схему на public для тестов, + // либо добавить инициализирующий скрипт для создания схемы. + + // В данном случае мы используем URL с параметром currentSchema=distcomp. + // Postgres в тестконтейнере может не иметь этой схемы. + // Поэтому добавим команду на создание схемы при старте контейнера: + postgres.withInitScript("db/test-init-schema.sql"); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/test/java/org/example/newsapi/controller/NewsControllerTest.java b/351004/Brazhalovich/Lab_1/src/test/java/org/example/newsapi/controller/NewsControllerTest.java new file mode 100644 index 000000000..4e83c7fbb --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/test/java/org/example/newsapi/controller/NewsControllerTest.java @@ -0,0 +1,135 @@ +package org.example.newsapi.controller; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.example.newsapi.AbstractIntegrationTest; +import org.example.newsapi.dto.request.NewsRequestTo; +import org.example.newsapi.entity.News; +import org.example.newsapi.entity.User; +import org.example.newsapi.repository.NewsRepository; +import org.example.newsapi.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.server.LocalServerPort; + +import java.util.List; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +class NewsControllerTest extends AbstractIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private NewsRepository newsRepository; + + @Autowired + private UserRepository userRepository; + + @BeforeEach + void setUp() { + RestAssured.port = port; + RestAssured.basePath = "/api/v1.0/news"; + } + + @AfterEach + void tearDown() { + // Очищаем новости после каждого теста, чтобы не влиять на другие тесты + newsRepository.deleteAll(); + } + + @Test + void shouldCreateNews() { + // ID=1 создается автоматически скриптом Liquibase (sashabrazhalovich2005@gmail.com) + Long userId = 1L; + + NewsRequestTo request = new NewsRequestTo(); + request.setUserId(userId); + request.setTitle("Breaking News"); + request.setContent("Something happened today."); + + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post() + .then() + .statusCode(201) + .body("id", notNullValue()) + .body("title", equalTo("Breaking News")) + .body("userId", equalTo(userId.intValue())); + } + + @Test + void shouldGetAllNewsWithPagination() { + // Подготовка данных напрямую через репозиторий + User user = userRepository.findById(1L).orElseThrow(); + + News news1 = News.builder().user(user).title("Title 1").content("Content 1").build(); + News news2 = News.builder().user(user).title("Title 2").content("Content 2").build(); + newsRepository.saveAll(List.of(news1, news2)); + + // Проверка GET запроса с пагинацией + given() + .param("page", 0) + .param("size", 10) + .param("sort", "id,asc") + .when() + .get() + .then() + .statusCode(200) + .body("content", hasSize(2)) + .body("content[0].title", equalTo("Title 1")) + .body("totalElements", equalTo(2)); + } + + @Test + void shouldUpdateNews() { + // Создаем новость + User user = userRepository.findById(1L).orElseThrow(); + News news = News.builder().user(user).title("Old Title").content("Old Content").build(); + news = newsRepository.save(news); + + // Формируем запрос на обновление + NewsRequestTo updateRequest = new NewsRequestTo(); + updateRequest.setUserId(user.getId()); // Автор остается тот же + updateRequest.setTitle("New Title"); + updateRequest.setContent("New Content"); + + given() + .contentType(ContentType.JSON) + .body(updateRequest) + .when() + .put("/{id}", news.getId()) + .then() + .statusCode(200) + .body("title", equalTo("New Title")) + .body("content", equalTo("New Content")); + } + + @Test + void shouldDeleteNews() { + // Создаем новость + User user = userRepository.findById(1L).orElseThrow(); + News news = News.builder().user(user).title("To Delete").content("...").build(); + news = newsRepository.save(news); + + // Удаляем + given() + .when() + .delete("/{id}", news.getId()) + .then() + .statusCode(204); + + // Проверяем, что удалилась (ожидаем 404 при попытке получить) + given() + .when() + .get("/{id}", news.getId()) + .then() + .statusCode(404); + } +} \ No newline at end of file diff --git a/351004/Brazhalovich/Lab_1/src/test/resources/db/test-init-schema.sql b/351004/Brazhalovich/Lab_1/src/test/resources/db/test-init-schema.sql new file mode 100644 index 000000000..f10ef46c6 --- /dev/null +++ b/351004/Brazhalovich/Lab_1/src/test/resources/db/test-init-schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA IF NOT EXISTS distcomp; \ No newline at end of file