commit 98d131caa475100d573524c665ee540820b9186e Author: Anastasia Tarazevich Date: Tue Jan 20 13:10:38 2026 +0300 feat: Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdcc9eb --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# 🏢 OfficeSpace - Приложение для бронирования рабочих мест + +### Эндпоинты сервера + +| Метод | Путь | Описание | +|-------|------|-----------| +| `GET` | `api/{code}/auth` | Проверка авторизации | +| `GET` | `api/{code}/info` | Получение информации о пользователе | +| `GET` | `api/{code}/booking` | Получение доступных для бронирования мест | +| `POST` | `api/{code}/book` | Создание нового бронирования | + +### Примеры ответов + +**Информация о пользователе:** +```json +{ + "name": "Иванов Петр Федорович", + "photoUrl": "https://example.com/photo.jpg", + "booking": { + "2025-01-05": {"id": 1, "place": "102"}, + "2025-01-06": {"id": 2, "place": "209.13"} + } +} +``` + +**Доступные для бронирования места:** +```json +{ + "2025-01-05": [ + {"id": 1, "place": "102"}, + {"id": 2, "place": "209.13"} + ] +} +``` +**Swagger-ui:** +http://localhost:8080/swagger-ui/index.html#/ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..93eee07 --- /dev/null +++ b/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + org.example + NTO-2025-Backend-Team-Task + 1.0-SNAPSHOT + + + 17 + 17 + UTF-8 + + + + org.springframework.boot + spring-boot-starter-parent + 3.5.5 + + + + + org.projectlombok + lombok + + + org.springframework.boot + spring-boot-starter-web + + + com.h2database + h2 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.liquibase + liquibase-core + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.8 + + + + \ No newline at end of file diff --git a/src/main/java/com/example/nto/App.java b/src/main/java/com/example/nto/App.java new file mode 100644 index 0000000..d4add94 --- /dev/null +++ b/src/main/java/com/example/nto/App.java @@ -0,0 +1,11 @@ +package com.example.nto; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class App { + public static void main(String[] args) { + SpringApplication.run(App.class, args); + } +} diff --git a/src/main/java/com/example/nto/controller/BookingController.java b/src/main/java/com/example/nto/controller/BookingController.java new file mode 100644 index 0000000..a4ed8b1 --- /dev/null +++ b/src/main/java/com/example/nto/controller/BookingController.java @@ -0,0 +1,35 @@ +package com.example.nto.controller; + +import com.example.nto.controller.dto.BookingCreateDto; +import com.example.nto.controller.dto.PlaceDto; +import com.example.nto.service.BookingService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Validated +@RestController +@RequestMapping("api") +@RequiredArgsConstructor +public class BookingController { + + private final BookingService bookingService; + + @GetMapping("/{code}/booking") + @ResponseStatus(code = HttpStatus.OK) + public Map> getByDate(@PathVariable String code) { + return bookingService.getFreePlace(code); + } + + @PostMapping("/{code}/book") + @ResponseStatus(code = HttpStatus.CREATED) + public void create(@PathVariable String code, @RequestBody BookingCreateDto bookingCreateDto) { + bookingService.create(code, bookingCreateDto); + } + +} diff --git a/src/main/java/com/example/nto/controller/EmployeeController.java b/src/main/java/com/example/nto/controller/EmployeeController.java new file mode 100644 index 0000000..7f73702 --- /dev/null +++ b/src/main/java/com/example/nto/controller/EmployeeController.java @@ -0,0 +1,29 @@ +package com.example.nto.controller; + + +import com.example.nto.controller.dto.EmployeeDto; +import com.example.nto.service.EmployeeService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("api") +@RequiredArgsConstructor +public class EmployeeController { + + private final EmployeeService employeeService; + + @GetMapping("/{code}/auth") + @ResponseStatus(code = HttpStatus.OK) + public void login(@PathVariable String code) { + employeeService.auth(code); + } + + @GetMapping("/{code}/info") + @ResponseStatus(code = HttpStatus.OK) + public EmployeeDto getByCode(@PathVariable String code) { + return employeeService.getByCode(code); + } + +} diff --git a/src/main/java/com/example/nto/controller/dto/BookingCreateDto.java b/src/main/java/com/example/nto/controller/dto/BookingCreateDto.java new file mode 100644 index 0000000..e2b7ddd --- /dev/null +++ b/src/main/java/com/example/nto/controller/dto/BookingCreateDto.java @@ -0,0 +1,21 @@ +package com.example.nto.controller.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookingCreateDto { + @NotNull + private LocalDate date; + @Positive + private long placeId; +} diff --git a/src/main/java/com/example/nto/controller/dto/EmployeeDto.java b/src/main/java/com/example/nto/controller/dto/EmployeeDto.java new file mode 100644 index 0000000..3c87566 --- /dev/null +++ b/src/main/java/com/example/nto/controller/dto/EmployeeDto.java @@ -0,0 +1,31 @@ +package com.example.nto.controller.dto; + +import com.example.nto.entity.Booking; +import com.example.nto.entity.Employee; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.Map; +import java.util.TreeMap; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class EmployeeDto { + private String name; + private String photoUrl; + private Map booking; + + public static EmployeeDto toDto(Employee employee) { + Map dtoTreeMap = new TreeMap<>(); + for (Booking booking : employee.getBookingList()) { + dtoTreeMap.put(booking.getDate(), PlaceDto.toDto(booking.getPlace())); + } + + return new EmployeeDto(employee.getName(), employee.getPhotoUrl(), dtoTreeMap); + } +} diff --git a/src/main/java/com/example/nto/controller/dto/PlaceDto.java b/src/main/java/com/example/nto/controller/dto/PlaceDto.java new file mode 100644 index 0000000..40dfda1 --- /dev/null +++ b/src/main/java/com/example/nto/controller/dto/PlaceDto.java @@ -0,0 +1,20 @@ +package com.example.nto.controller.dto; + +import com.example.nto.entity.Place; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PlaceDto { + private long id; + private String place; + + public static PlaceDto toDto(Place place) { + return new PlaceDto(place.getId(), place.getPlace()); + } +} diff --git a/src/main/java/com/example/nto/entity/Booking.java b/src/main/java/com/example/nto/entity/Booking.java new file mode 100644 index 0000000..a208eb9 --- /dev/null +++ b/src/main/java/com/example/nto/entity/Booking.java @@ -0,0 +1,33 @@ +package com.example.nto.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@Entity +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "booking") +public class Booking { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(name = "date") + private LocalDate date; + + @ManyToOne(targetEntity = Place.class, fetch = FetchType.LAZY) + @JoinColumn(name = "place_id") + private Place place; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "employee_id") + private Employee employee; +} diff --git a/src/main/java/com/example/nto/entity/Employee.java b/src/main/java/com/example/nto/entity/Employee.java new file mode 100644 index 0000000..e854a92 --- /dev/null +++ b/src/main/java/com/example/nto/entity/Employee.java @@ -0,0 +1,34 @@ +package com.example.nto.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Entity +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "employee") +public class Employee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(name = "name") + private String name; + + @Column(name = "code") + private String code; + + @Column(name = "photo_url") + private String photoUrl; + + @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List bookingList; +} diff --git a/src/main/java/com/example/nto/entity/Place.java b/src/main/java/com/example/nto/entity/Place.java new file mode 100644 index 0000000..c266212 --- /dev/null +++ b/src/main/java/com/example/nto/entity/Place.java @@ -0,0 +1,23 @@ +package com.example.nto.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Entity +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "place") +public class Place { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(name = "place_name") + private String place; +} diff --git a/src/main/java/com/example/nto/exception/BookingAlreadyExistsException.java b/src/main/java/com/example/nto/exception/BookingAlreadyExistsException.java new file mode 100644 index 0000000..019136d --- /dev/null +++ b/src/main/java/com/example/nto/exception/BookingAlreadyExistsException.java @@ -0,0 +1,7 @@ +package com.example.nto.exception; + +public class BookingAlreadyExistsException extends RuntimeException { + public BookingAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/nto/exception/EmployeeNotFoundException.java b/src/main/java/com/example/nto/exception/EmployeeNotFoundException.java new file mode 100644 index 0000000..d427077 --- /dev/null +++ b/src/main/java/com/example/nto/exception/EmployeeNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.nto.exception; + +public class EmployeeNotFoundException extends RuntimeException { + public EmployeeNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/nto/exception/PlaceNotFoundException.java b/src/main/java/com/example/nto/exception/PlaceNotFoundException.java new file mode 100644 index 0000000..2560027 --- /dev/null +++ b/src/main/java/com/example/nto/exception/PlaceNotFoundException.java @@ -0,0 +1,7 @@ +package com.example.nto.exception; + +public class PlaceNotFoundException extends RuntimeException { + public PlaceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/nto/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/example/nto/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..234a158 --- /dev/null +++ b/src/main/java/com/example/nto/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,32 @@ +package com.example.nto.exception.handler; + +import com.example.nto.exception.BookingAlreadyExistsException; +import com.example.nto.exception.EmployeeNotFoundException; +import com.example.nto.exception.PlaceNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(EmployeeNotFoundException.class) + public ResponseEntity handleEmployeeNotFoundException(EmployeeNotFoundException e) { + return new ResponseEntity<>(e.getMessage(), HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(BookingAlreadyExistsException.class) + public ResponseEntity handleBookingAlreadyExistsException(BookingAlreadyExistsException e) { + return new ResponseEntity<>(e.getMessage(), HttpStatus.CONFLICT); + } + + @ExceptionHandler(PlaceNotFoundException.class) + public ResponseEntity handlePlaceNotFoundException(PlaceNotFoundException e) { + return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception e) { + return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/com/example/nto/repository/BookingRepository.java b/src/main/java/com/example/nto/repository/BookingRepository.java new file mode 100644 index 0000000..7d4093b --- /dev/null +++ b/src/main/java/com/example/nto/repository/BookingRepository.java @@ -0,0 +1,18 @@ +package com.example.nto.repository; + +import com.example.nto.entity.Booking; +import com.example.nto.entity.Employee; +import com.example.nto.entity.Place; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface BookingRepository extends JpaRepository { + List findByDateBetween(LocalDate start, LocalDate end); + + Optional findByDateAndPlace(LocalDate date, Place place); + + Optional findByDateAndEmployee(LocalDate date, Employee employee); +} diff --git a/src/main/java/com/example/nto/repository/EmployeeRepository.java b/src/main/java/com/example/nto/repository/EmployeeRepository.java new file mode 100644 index 0000000..d845a04 --- /dev/null +++ b/src/main/java/com/example/nto/repository/EmployeeRepository.java @@ -0,0 +1,12 @@ +package com.example.nto.repository; + +import com.example.nto.entity.Employee; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EmployeeRepository extends JpaRepository { + @EntityGraph(attributePaths = {"bookingList", "bookingList.place"}) + Optional findByCode(String code); +} diff --git a/src/main/java/com/example/nto/repository/PlaceRepository.java b/src/main/java/com/example/nto/repository/PlaceRepository.java new file mode 100644 index 0000000..e7b84c9 --- /dev/null +++ b/src/main/java/com/example/nto/repository/PlaceRepository.java @@ -0,0 +1,7 @@ +package com.example.nto.repository; + +import com.example.nto.entity.Place; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PlaceRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/nto/service/BookingService.java b/src/main/java/com/example/nto/service/BookingService.java new file mode 100644 index 0000000..64e6ac6 --- /dev/null +++ b/src/main/java/com/example/nto/service/BookingService.java @@ -0,0 +1,15 @@ +package com.example.nto.service; + +import com.example.nto.controller.dto.BookingCreateDto; +import com.example.nto.controller.dto.PlaceDto; +import com.example.nto.entity.Booking; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public interface BookingService { + Map> getFreePlace(String code); + + Booking create(String code, BookingCreateDto bookingCreateDto); +} diff --git a/src/main/java/com/example/nto/service/EmployeeService.java b/src/main/java/com/example/nto/service/EmployeeService.java new file mode 100644 index 0000000..83144af --- /dev/null +++ b/src/main/java/com/example/nto/service/EmployeeService.java @@ -0,0 +1,9 @@ +package com.example.nto.service; + +import com.example.nto.controller.dto.EmployeeDto; + +public interface EmployeeService { + EmployeeDto getByCode(String code); + + void auth(String code); +} diff --git a/src/main/java/com/example/nto/service/impl/BookingServiceImpl.java b/src/main/java/com/example/nto/service/impl/BookingServiceImpl.java new file mode 100644 index 0000000..ffc4f86 --- /dev/null +++ b/src/main/java/com/example/nto/service/impl/BookingServiceImpl.java @@ -0,0 +1,105 @@ +package com.example.nto.service.impl; + +import com.example.nto.controller.dto.BookingCreateDto; +import com.example.nto.controller.dto.PlaceDto; +import com.example.nto.entity.Booking; +import com.example.nto.entity.Employee; +import com.example.nto.entity.Place; +import com.example.nto.exception.BookingAlreadyExistsException; +import com.example.nto.exception.EmployeeNotFoundException; +import com.example.nto.exception.PlaceNotFoundException; +import com.example.nto.repository.BookingRepository; +import com.example.nto.repository.EmployeeRepository; +import com.example.nto.repository.PlaceRepository; +import com.example.nto.service.BookingService; +import com.example.nto.service.EmployeeService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BookingServiceImpl implements BookingService { + + private final BookingRepository bookingRepository; + private final EmployeeRepository employeeRepository; + private final PlaceRepository placeRepository; + private final EmployeeService employeeService; + + @Value("${booking.days-ahead}") + private int daysAhead; + + @Override + @Transactional(readOnly = true) + public Map> getFreePlace(String code) { + employeeService.auth(code); + + List allPlaces = placeRepository.findAll(); + + LocalDate today = LocalDate.now(ZoneId.systemDefault()); + LocalDate end = today.plusDays(daysAhead); + + List bookings = bookingRepository.findByDateBetween(today, end); + + Map> busyByDate = bookings.stream() + .collect(Collectors.groupingBy( + Booking::getDate, + Collectors.mapping(b -> b.getPlace().getId(), Collectors.toSet()) + )); + + Map> result = new LinkedHashMap<>(); + + for (int i = 0; i <= daysAhead; i++) { + LocalDate currentDate = today.plusDays(i); + Set busyPlaces = busyByDate.getOrDefault(currentDate, Collections.emptySet()); + + List freePlaces = allPlaces.stream() + .filter(place -> !busyPlaces.contains(place.getId())) + .map(place -> new PlaceDto(place.getId(), place.getPlace())) + .toList(); + + result.put(currentDate, freePlaces); + } + + return result; + } + + @Override + @Transactional + public Booking create(String code, BookingCreateDto bookingCreateDto) { + LocalDate date = bookingCreateDto.getDate(); + LocalDate today = LocalDate.now(ZoneId.systemDefault()); + if (date.isBefore(today) || date.isAfter(today.plusDays(daysAhead))) { + throw new IllegalArgumentException("Date is out of booking window"); + } + + Employee employee = employeeRepository.findByCode(code) + .orElseThrow(() -> new EmployeeNotFoundException("Employee with " + code + " code not found!")); + + long placeId = bookingCreateDto.getPlaceId(); + Place place = placeRepository.findById(placeId) + .orElseThrow(() -> new PlaceNotFoundException("Place with " + placeId + " id not found!")); + + if (bookingRepository.findByDateAndPlace(date, place).isPresent()) { + throw new BookingAlreadyExistsException("Booking already exists"); + } + + if (bookingRepository.findByDateAndEmployee(date, employee).isPresent()) { + throw new BookingAlreadyExistsException("This employee already has another booking on " + date); + } + + Booking booking = Booking.builder() + .date(date) + .employee(employee) + .place(place) + .build(); + + return bookingRepository.save(booking); + } +} diff --git a/src/main/java/com/example/nto/service/impl/EmployeeServiceImpl.java b/src/main/java/com/example/nto/service/impl/EmployeeServiceImpl.java new file mode 100644 index 0000000..3085dc2 --- /dev/null +++ b/src/main/java/com/example/nto/service/impl/EmployeeServiceImpl.java @@ -0,0 +1,31 @@ +package com.example.nto.service.impl; + +import com.example.nto.controller.dto.EmployeeDto; +import com.example.nto.exception.EmployeeNotFoundException; +import com.example.nto.repository.EmployeeRepository; +import com.example.nto.service.EmployeeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class EmployeeServiceImpl implements EmployeeService { + + private final EmployeeRepository employeeRepository; + + @Override + @Transactional(readOnly = true) + public EmployeeDto getByCode(String code) { + return employeeRepository.findByCode(code).map(EmployeeDto::toDto) + .orElseThrow(() -> new EmployeeNotFoundException("Employee with " + code + " code not found!")); + } + + @Override + @Transactional(readOnly = true) + public void auth(String code) { + if (employeeRepository.findByCode(code).isEmpty()) { + throw new EmployeeNotFoundException("Employee with " + code + " code not found!"); + } + } +} diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..55c2da4 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,22 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + + h2: + console: + enabled: true + + jpa: + generate-ddl: false + + hibernate: + ddl-auto: none + + show-sql: true + + liquibase: + enabled: true + change-log: classpath:db.changelog/db.changelog-test-master.xml + +booking: + days-ahead: 3 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..8b68191 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,22 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + + h2: + console: + enabled: true + + jpa: + generate-ddl: false + + hibernate: + ddl-auto: none + + show-sql: true + + liquibase: + enabled: true + change-log: classpath:db.changelog/db.changelog-master.xml + +booking: + days-ahead: 3 \ No newline at end of file diff --git a/src/main/resources/db.changelog/1/0/2025-11-05--0001-employee.xml b/src/main/resources/db.changelog/1/0/2025-11-05--0001-employee.xml new file mode 100644 index 0000000..d1f92f8 --- /dev/null +++ b/src/main/resources/db.changelog/1/0/2025-11-05--0001-employee.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db.changelog/1/0/2025-11-05--0002-place.xml b/src/main/resources/db.changelog/1/0/2025-11-05--0002-place.xml new file mode 100644 index 0000000..db4a2b2 --- /dev/null +++ b/src/main/resources/db.changelog/1/0/2025-11-05--0002-place.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db.changelog/1/0/2025-11-05--0003-booking.xml b/src/main/resources/db.changelog/1/0/2025-11-05--0003-booking.xml new file mode 100644 index 0000000..fa62dce --- /dev/null +++ b/src/main/resources/db.changelog/1/0/2025-11-05--0003-booking.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db.changelog/data/2025-11-05--0001-employee-data.xml b/src/main/resources/db.changelog/data/2025-11-05--0001-employee-data.xml new file mode 100644 index 0000000..40f8ddb --- /dev/null +++ b/src/main/resources/db.changelog/data/2025-11-05--0001-employee-data.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db.changelog/data/2025-11-05--0002-place-data.xml b/src/main/resources/db.changelog/data/2025-11-05--0002-place-data.xml new file mode 100644 index 0000000..e5351dd --- /dev/null +++ b/src/main/resources/db.changelog/data/2025-11-05--0002-place-data.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db.changelog/data/2025-11-05--0003-booking-data.xml b/src/main/resources/db.changelog/data/2025-11-05--0003-booking-data.xml new file mode 100644 index 0000000..eea0c6b --- /dev/null +++ b/src/main/resources/db.changelog/data/2025-11-05--0003-booking-data.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db.changelog/data/csv/2025-11-05--0001-employee-data.csv b/src/main/resources/db.changelog/data/csv/2025-11-05--0001-employee-data.csv new file mode 100644 index 0000000..87ddc6b --- /dev/null +++ b/src/main/resources/db.changelog/data/csv/2025-11-05--0001-employee-data.csv @@ -0,0 +1,5 @@ +name;code;photo_url +Ivanov Ivan;1111;https://catalog-cdn.detmir.st/media/2fe02057f9915e72a378795d32c79ea9.jpeg +Petrov Petr;2222;https://catalog-cdn.detmir.st/media/2fe02057f9915e72a378795d32c79ea9.jpeg +Kozlov Oleg;3333;https://catalog-cdn.detmir.st/media/2fe02057f9915e72a378795d32c79ea9.jpeg +Smirnova Anna;4444;https://catalog-cdn.detmir.st/media/2fe02057f9915e72a378795d32c79ea9.jpeg \ No newline at end of file diff --git a/src/main/resources/db.changelog/data/csv/2025-11-05--0002-place-data.csv b/src/main/resources/db.changelog/data/csv/2025-11-05--0002-place-data.csv new file mode 100644 index 0000000..3354529 --- /dev/null +++ b/src/main/resources/db.changelog/data/csv/2025-11-05--0002-place-data.csv @@ -0,0 +1,4 @@ +place_name +K-19 +M-16 +T-1 \ No newline at end of file diff --git a/src/main/resources/db.changelog/data/csv/2025-11-05--0003-booking-data.csv b/src/main/resources/db.changelog/data/csv/2025-11-05--0003-booking-data.csv new file mode 100644 index 0000000..11f0364 --- /dev/null +++ b/src/main/resources/db.changelog/data/csv/2025-11-05--0003-booking-data.csv @@ -0,0 +1,3 @@ +date;place_id;employee_id +2025-11-08;1;1 +2025-11-10;2;2 \ No newline at end of file diff --git a/src/main/resources/db.changelog/db.changelog-master.xml b/src/main/resources/db.changelog/db.changelog-master.xml new file mode 100644 index 0000000..90031f0 --- /dev/null +++ b/src/main/resources/db.changelog/db.changelog-master.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/main/resources/db.changelog/db.changelog-test-master.xml b/src/main/resources/db.changelog/db.changelog-test-master.xml new file mode 100644 index 0000000..f1e29f7 --- /dev/null +++ b/src/main/resources/db.changelog/db.changelog-test-master.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/src/main/resources/db.changelog/test-data/csv/test-booking-data.csv b/src/main/resources/db.changelog/test-data/csv/test-booking-data.csv new file mode 100644 index 0000000..0180d50 --- /dev/null +++ b/src/main/resources/db.changelog/test-data/csv/test-booking-data.csv @@ -0,0 +1,3 @@ +date;place_id;employee_id +2025-11-08;1;1 +2025-11-09;2;2 \ No newline at end of file diff --git a/src/main/resources/db.changelog/test-data/csv/test-employee-data.csv b/src/main/resources/db.changelog/test-data/csv/test-employee-data.csv new file mode 100644 index 0000000..801df23 --- /dev/null +++ b/src/main/resources/db.changelog/test-data/csv/test-employee-data.csv @@ -0,0 +1,3 @@ +name;code;photo_url +Test User 1;2104asd;https://example.org/u1.jpg +Test User 2;qwe1206;https://example.org/u2.jpg \ No newline at end of file diff --git a/src/main/resources/db.changelog/test-data/csv/test-place-data.csv b/src/main/resources/db.changelog/test-data/csv/test-place-data.csv new file mode 100644 index 0000000..e4abd33 --- /dev/null +++ b/src/main/resources/db.changelog/test-data/csv/test-place-data.csv @@ -0,0 +1,4 @@ +place_name +T-01 +T-02 +T-03 \ No newline at end of file diff --git a/src/main/resources/db.changelog/test-data/test-booking-data.xml b/src/main/resources/db.changelog/test-data/test-booking-data.xml new file mode 100644 index 0000000..af20d95 --- /dev/null +++ b/src/main/resources/db.changelog/test-data/test-booking-data.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db.changelog/test-data/test-employee-data.xml b/src/main/resources/db.changelog/test-data/test-employee-data.xml new file mode 100644 index 0000000..52a9048 --- /dev/null +++ b/src/main/resources/db.changelog/test-data/test-employee-data.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db.changelog/test-data/test-place-data.xml b/src/main/resources/db.changelog/test-data/test-place-data.xml new file mode 100644 index 0000000..1098351 --- /dev/null +++ b/src/main/resources/db.changelog/test-data/test-place-data.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/example/nto/intagration/BookingControllerIntegrationTest.java b/src/test/java/com/example/nto/intagration/BookingControllerIntegrationTest.java new file mode 100644 index 0000000..75a65ab --- /dev/null +++ b/src/test/java/com/example/nto/intagration/BookingControllerIntegrationTest.java @@ -0,0 +1,161 @@ +package com.example.nto.intagration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Order; +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.http.MediaType; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Map; + +import static org.hamcrest.Matchers.*; +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.*; + +@Rollback +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class BookingControllerIntegrationTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Test + @Order(1) + @DisplayName("GET /api/{code}/booking -> 200 и карта свободных мест на N дней вперёд") + void getFreePlaces_ok_whenCodeValid() throws Exception { + String today = java.time.LocalDate.now().toString(); + + mockMvc.perform(get("/api/{code}/booking", "2104asd") + .accept(org.springframework.http.MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(org.springframework.http.MediaType.APPLICATION_JSON)) + // мапа дат -> список доступных мест + .andExpect(jsonPath("$").isMap()) + // размер мапы строго равен daysAhead + 1 (включая сегодня) + .andExpect(jsonPath("$", aMapWithSize(3 + 1))) + // на сегодняшнюю дату возвращается массив из трёх мест + .andExpect(jsonPath("$['" + today + "']").isArray()) + .andExpect(jsonPath("$['" + today + "']", hasSize(3))) + // проверяем состав мест без привязки к порядку + .andExpect(jsonPath("$['" + today + "'][*].place", + containsInAnyOrder("T-01", "T-02", "T-03"))) + .andExpect(jsonPath("$['" + today + "'][*].id", + containsInAnyOrder(1, 2, 3))); + } + + @Test + @Order(2) + @DisplayName("GET /api/{code}/booking -> 401 при неверном коде") + void getFreePlaces_unauthorized_whenCodeInvalid() throws Exception { + mockMvc.perform(get("/api/{code}/booking", "0000")) + .andExpect(status().isUnauthorized()); + } + + @Test + @Order(3) + @DisplayName("POST /api/{code}/book -> 201 при успешном бронировании свободного места") + void book_ok_createsBooking() throws Exception { + LocalDate date = LocalDate.now(); + Map body = Map.of( + "date", date.toString(), + "placeId", 2 + ); + + mockMvc.perform(post("/api/{code}/book", "2104asd") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isCreated()) + .andExpect(content().string(isEmptyString())); + } + + @Test + @Order(4) + @DisplayName("POST /api/{code}/book -> 409 если место на дату уже занято") + void book_conflict_whenPlaceAlreadyBooked() throws Exception { + LocalDate date = LocalDate.now(); + Map body = Map.of( + "date", date.toString(), + "placeId", 2 + ); + mockMvc.perform(post("/api/{code}/book", "2104asd") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))); + + + mockMvc.perform(post("/api/{code}/book", "qwe1206") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isConflict()); + } + + @Test + @Order(5) + @DisplayName("POST /api/{code}/book -> 409 если у сотрудника на указанную дату уже забронировано другое место") + void book_conflict_whenAlreadyHasAnotherBooking() throws Exception { + LocalDate date = LocalDate.now(); + Map body = Map.of( + "date", date.toString(), + "placeId", 2 + ); + mockMvc.perform(post("/api/{code}/book", "2104asd") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))); + + + body = Map.of( + "date", date.toString(), + "placeId", 1 + ); + mockMvc.perform(post("/api/{code}/book", "2104asd") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isConflict()); + } + + @Test + @Order(6) + @DisplayName("POST /api/{code}/book -> 400 если указан несуществующий placeId") + void book_badRequest_whenPlaceNotFound() throws Exception { + LocalDate anyDate = LocalDate.now().plusDays(1); + Map body = Map.of( + "date", anyDate.toString(), + "placeId", 999_999 + ); + + mockMvc.perform(post("/api/{code}/book", "2104asd") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(7) + @DisplayName("POST /api/{code}/book -> 401 при неверном коде") + void book_unauthorized_whenCodeInvalid() throws Exception { + LocalDate anyDate = LocalDate.now().plusDays(1); + Map body = Map.of( + "date", anyDate.toString(), + "placeId", 1 + ); + + mockMvc.perform(post("/api/{code}/book", "0000") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isUnauthorized()); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/nto/intagration/EmployeeControllerIntegrationTest.java b/src/test/java/com/example/nto/intagration/EmployeeControllerIntegrationTest.java new file mode 100644 index 0000000..f454caf --- /dev/null +++ b/src/test/java/com/example/nto/intagration/EmployeeControllerIntegrationTest.java @@ -0,0 +1,67 @@ +package com.example.nto.intagration; + +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.isEmptyString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class EmployeeControllerIntegrationTest { + + @Autowired + MockMvc mockMvc; + + @Test + @DisplayName("GET /api/{code}/auth -> 200 при существующем коде") + void auth_ok_whenCodeExists() throws Exception { + mockMvc.perform(get("/api/{code}/auth", "2104asd")) + .andExpect(status().isOk()) + .andExpect(content().string(isEmptyString())); + } + + @Test + @DisplayName("GET /api/{code}/auth -> 401 при несуществующем коде") + void auth_unauthorized_whenCodeNotExists() throws Exception { + mockMvc.perform(get("/api/{code}/auth", "9999")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("GET /api/{code}/info -> 200 и корректный JSON профиля с бронированиями") + void info_ok_returnsEmployeeDto() throws Exception { + mockMvc.perform(get("/api/{code}/info", "qwe1206") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.name").value("Test User 2")) + .andExpect(jsonPath("$.photoUrl").value("https://example.org/u2.jpg")) + .andExpect(jsonPath("$.booking").isMap()) + + // есть ключ 2025-11-09 и это именно объект PlaceDto, НЕ массив + .andExpect(jsonPath("$.booking['2025-11-09']").isMap()) + .andExpect(jsonPath("$.booking['2025-11-09'].id").value(2)) + .andExpect(jsonPath("$.booking['2025-11-09'].place").value("T-02")) + + // других дат нет и размер мапы = 1 + .andExpect(jsonPath("$.booking['2025-11-08']").doesNotExist()) + .andExpect(jsonPath("$.booking", aMapWithSize(1))); + } + + @Test + @DisplayName("GET /api/{code}/info -> 401 при неверном коде") + void info_unauthorized_whenCodeInvalid() throws Exception { + mockMvc.perform(get("/api/{code}/info", "0000")) + .andExpect(status().isUnauthorized()); + } +}