diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index ea94ec7..58cd7db 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -42,6 +42,7 @@ jobs: SPRING_PROFILES_ACTIVE: ${{ secrets.SPRING_PROFILES_ACTIVE }} EMAIL_USERNAME: ${{ secrets.EMAIL_USERNAME }} EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }} + JWT_SECRET_KEY: ${{secrets.JWT_SECRET_KEY}} steps: - name: Checkout code @@ -112,4 +113,4 @@ jobs: cache-to: type=gha,mode=max build-args: | EMAIL_USERNAME=${{ secrets.EMAIL_USERNAME }} - EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }} \ No newline at end of file + EMAIL_PASSWORD=${{ secrets.EMAIL_PASSWORD }} diff --git a/.gitignore b/.gitignore index 5868be2..c6a22f5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ target/ .mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ +logs/* ### STS ### .apt_generated diff --git a/docker-compose.yml b/docker-compose.yml index a722b7d..87636af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,15 @@ version: "3.9" services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: pkwmtt-backend + ports: + - "8080:8080" + restart: always + environment: + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} db: image: mysql environment: diff --git a/init.sql b/init.sql index 7d91d29..5d8ed30 100644 --- a/init.sql +++ b/init.sql @@ -1,11 +1,11 @@ -- phpMyAdmin SQL Dump --- version 5.2.2 +-- version 5.2.0 -- https://www.phpmyadmin.net/ -- --- Host: db --- Generation Time: Aug 18, 2025 at 07:00 PM --- Wersja serwera: 9.3.0 --- Wersja PHP: 8.2.27 +-- Host: 127.0.0.1 +-- Czas generowania: 06 Wrz 2025, 23:16 +-- Wersja serwera: 10.4.27-MariaDB +-- Wersja PHP: 8.0.25 SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; START TRANSACTION; @@ -20,29 +20,70 @@ SET time_zone = "+00:00"; -- -- Baza danych: `pktt` -- -CREATE DATABASE IF NOT EXISTS `pktt` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +CREATE DATABASE IF NOT EXISTS `pktt` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE `pktt`; -- -------------------------------------------------------- +-- +-- Struktura tabeli dla tabeli `admin_keys` +-- + +DROP TABLE IF EXISTS `admin_keys`; +CREATE TABLE IF NOT EXISTS `admin_keys` ( + `key_id` int(11) NOT NULL AUTO_INCREMENT, + `value` varchar(255) NOT NULL, + `description` varchar(255) NOT NULL, + PRIMARY KEY (`key_id`), + UNIQUE KEY `unique_value` (`value`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Zrzut danych tabeli `admin_keys` +-- + +INSERT INTO `admin_keys` (`key_id`, `value`, `description`) VALUES +(3, '0923cd6f-cd33-4883-87e4-ae3b50b80a3f', 'mikolaj'); + +-- -------------------------------------------------------- + +-- +-- Struktura tabeli dla tabeli `api_keys` +-- + +DROP TABLE IF EXISTS `api_keys`; +CREATE TABLE IF NOT EXISTS `api_keys` ( + `key_id` int(11) NOT NULL AUTO_INCREMENT, + `value` varchar(255) NOT NULL, + `description` varchar(255) NOT NULL, + PRIMARY KEY (`key_id`), + UNIQUE KEY `unique_value` (`value`) +) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Zrzut danych tabeli `api_keys` +-- + +INSERT INTO `api_keys` (`key_id`, `value`, `description`) VALUES +(1, 'ca3bdabb-b559-41ca-9e96-2c27d6199017', 'test'); + +-- -------------------------------------------------------- + -- -- Struktura tabeli dla tabeli `exams` -- DROP TABLE IF EXISTS `exams`; -CREATE TABLE `exams` ( - `exam_id` int NOT NULL, +CREATE TABLE IF NOT EXISTS `exams` ( + `exam_id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL, `description` varchar(255) DEFAULT NULL, `exam_date` datetime NOT NULL, - `exam_type_id` int NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - --- --- Tabela Truncate przed wstawieniem `exams` --- + `exam_type_id` int(11) NOT NULL, + PRIMARY KEY (`exam_id`), + KEY `exam_type_id_idx` (`exam_type_id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -TRUNCATE TABLE `exams`; -- -- Zrzut danych tabeli `exams` -- @@ -62,17 +103,15 @@ INSERT INTO `exams` (`exam_id`, `title`, `description`, `exam_date`, `exam_type_ -- DROP TABLE IF EXISTS `exams_groups`; -CREATE TABLE `exams_groups` ( - `exam_group_id` int NOT NULL, - `exam_id` int NOT NULL, - `group_id` int NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - --- --- Tabela Truncate przed wstawieniem `exams_groups` --- +CREATE TABLE IF NOT EXISTS `exams_groups` ( + `exam_group_id` int(11) NOT NULL AUTO_INCREMENT, + `exam_id` int(11) NOT NULL, + `group_id` int(11) NOT NULL, + PRIMARY KEY (`exam_group_id`), + KEY `exam_id_idx` (`exam_id`), + KEY `group_id_idx` (`group_id`) +) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -TRUNCATE TABLE `exams_groups`; -- -- Zrzut danych tabeli `exams_groups` -- @@ -100,16 +139,12 @@ INSERT INTO `exams_groups` (`exam_group_id`, `exam_id`, `group_id`) VALUES -- DROP TABLE IF EXISTS `exam_type`; -CREATE TABLE `exam_type` ( - `exam_type_id` int NOT NULL, - `name` varchar(255) NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - --- --- Tabela Truncate przed wstawieniem `exam_type` --- +CREATE TABLE IF NOT EXISTS `exam_type` ( + `exam_type_id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + PRIMARY KEY (`exam_type_id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -TRUNCATE TABLE `exam_type`; -- -- Zrzut danych tabeli `exam_type` -- @@ -126,16 +161,12 @@ INSERT INTO `exam_type` (`exam_type_id`, `name`) VALUES -- DROP TABLE IF EXISTS `general_group`; -CREATE TABLE `general_group` ( - `general_group_id` int NOT NULL, - `name` varchar(255) NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +CREATE TABLE IF NOT EXISTS `general_group` ( + `general_group_id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + PRIMARY KEY (`general_group_id`) +) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; --- --- Tabela Truncate przed wstawieniem `general_group` --- - -TRUNCATE TABLE `general_group`; -- -- Zrzut danych tabeli `general_group` -- @@ -149,25 +180,48 @@ INSERT INTO `general_group` (`general_group_id`, `name`) VALUES -- -------------------------------------------------------- -- --- Struktura tabeli dla tabeli `groups` +-- Struktura tabeli dla tabeli `otp_codes` -- -DROP TABLE IF EXISTS `groups`; -CREATE TABLE `groups` ( - `group_id` int NOT NULL, - `name` varchar(255) NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +DROP TABLE IF EXISTS `otp_codes`; +CREATE TABLE IF NOT EXISTS `otp_codes` ( + `otp_code_id` int(11) NOT NULL AUTO_INCREMENT, + `code` varchar(255) NOT NULL, + `expire` timestamp NOT NULL DEFAULT current_timestamp(), + `general_group_id` int(11) NOT NULL, + PRIMARY KEY (`otp_code_id`), + KEY `general_group_id_idx` (`general_group_id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- +-- Zrzut danych tabeli `otp_codes` +-- + +INSERT INTO `otp_codes` (`otp_code_id`, `code`, `expire`, `general_group_id`) VALUES +(1, 'ABC123', '2025-08-18 19:51:40', 17), +(2, 'XYZ789', '2025-08-18 20:51:40', 18), +(3, 'QWE456', '2025-08-18 21:51:40', 19), +(4, 'JKL999', '2025-08-18 22:51:40', 20); + +-- -------------------------------------------------------- -- --- Tabela Truncate przed wstawieniem `groups` +-- Struktura tabeli dla tabeli `student_groups` -- -TRUNCATE TABLE `groups`; +DROP TABLE IF EXISTS `student_groups`; +CREATE TABLE IF NOT EXISTS `student_groups` ( + `group_id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + PRIMARY KEY (`group_id`), + UNIQUE KEY `name` (`name`) +) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + -- --- Zrzut danych tabeli `groups` +-- Zrzut danych tabeli `student_groups` -- -INSERT INTO `groups` (`group_id`, `name`) VALUES +INSERT INTO `student_groups` (`group_id`, `name`) VALUES (9, '11A1'), (10, '11A2'), (12, '12E1'), @@ -180,53 +234,21 @@ INSERT INTO `groups` (`group_id`, `name`) VALUES -- -------------------------------------------------------- --- --- Struktura tabeli dla tabeli `otp_codes` --- - -DROP TABLE IF EXISTS `otp_codes`; -CREATE TABLE `otp_codes` ( - `otp_code_id` int NOT NULL, - `code` varchar(255) NOT NULL, - `expire` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `general_group_id` int NOT NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - --- --- Tabela Truncate przed wstawieniem `otp_codes` --- - -TRUNCATE TABLE `otp_codes`; --- --- Zrzut danych tabeli `otp_codes` --- - -INSERT INTO `otp_codes` (`otp_code_id`, `code`, `expire`, `general_group_id`) VALUES -(1, 'ABC123', '2025-08-18 19:51:40', 17), -(2, 'XYZ789', '2025-08-18 20:51:40', 18), -(3, 'QWE456', '2025-08-18 21:51:40', 19), -(4, 'JKL999', '2025-08-18 22:51:40', 20); - --- -------------------------------------------------------- - -- -- Struktura tabeli dla tabeli `users` -- DROP TABLE IF EXISTS `users`; -CREATE TABLE `users` ( - `user_id` int NOT NULL, - `general_group_id` int NOT NULL, +CREATE TABLE IF NOT EXISTS `users` ( + `user_id` int(11) NOT NULL AUTO_INCREMENT, + `general_group_id` int(11) NOT NULL, `email` varchar(255) NOT NULL, - `is_active` tinyint(1) NOT NULL DEFAULT '1', - `role` enum('ADMIN','REPRESENTATIVE') NOT NULL DEFAULT 'REPRESENTATIVE' -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; - --- --- Tabela Truncate przed wstawieniem `users` --- + `is_active` tinyint(1) NOT NULL DEFAULT 1, + `role` enum('ADMIN','REPRESENTATIVE') NOT NULL DEFAULT 'REPRESENTATIVE', + PRIMARY KEY (`user_id`), + KEY `general_group_id_idx` (`general_group_id`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -TRUNCATE TABLE `users`; -- -- Zrzut danych tabeli `users` -- @@ -237,103 +259,6 @@ INSERT INTO `users` (`user_id`, `general_group_id`, `email`, `is_active`, `role` (3, 19, 'user13k@example.com', 1, 'REPRESENTATIVE'), (4, 20, 'user14m@example.com', 1, 'ADMIN'); --- --- Indeksy dla zrzutów tabel --- - --- --- Indeksy dla tabeli `exams` --- -ALTER TABLE `exams` - ADD PRIMARY KEY (`exam_id`), - ADD KEY `exam_type_id_idx` (`exam_type_id`); - --- --- Indeksy dla tabeli `exams_groups` --- -ALTER TABLE `exams_groups` - ADD PRIMARY KEY (`exam_group_id`), - ADD KEY `exam_id_idx` (`exam_id`), - ADD KEY `group_id_idx` (`group_id`); - --- --- Indeksy dla tabeli `exam_type` --- -ALTER TABLE `exam_type` - ADD PRIMARY KEY (`exam_type_id`); - --- --- Indeksy dla tabeli `general_group` --- -ALTER TABLE `general_group` - ADD PRIMARY KEY (`general_group_id`); - --- --- Indeksy dla tabeli `groups` --- -ALTER TABLE `groups` - ADD PRIMARY KEY (`group_id`); - --- --- Indeksy dla tabeli `otp_codes` --- -ALTER TABLE `otp_codes` - ADD PRIMARY KEY (`otp_code_id`), - ADD KEY `general_group_id_idx` (`general_group_id`); - --- --- Indeksy dla tabeli `users` --- -ALTER TABLE `users` - ADD PRIMARY KEY (`user_id`), - ADD KEY `general_group_id_idx` (`general_group_id`); - --- --- AUTO_INCREMENT dla zrzuconych tabel --- - --- --- AUTO_INCREMENT dla tabeli `exams` --- -ALTER TABLE `exams` - MODIFY `exam_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=7; - --- --- AUTO_INCREMENT dla tabeli `exams_groups` --- -ALTER TABLE `exams_groups` - MODIFY `exam_group_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=21; - --- --- AUTO_INCREMENT dla tabeli `exam_type` --- -ALTER TABLE `exam_type` - MODIFY `exam_type_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; - --- --- AUTO_INCREMENT dla tabeli `general_group` --- -ALTER TABLE `general_group` - MODIFY `general_group_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=21; - --- --- AUTO_INCREMENT dla tabeli `groups` --- -ALTER TABLE `groups` - MODIFY `group_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=21; - --- --- AUTO_INCREMENT dla tabeli `otp_codes` --- -ALTER TABLE `otp_codes` - MODIFY `otp_code_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5; - --- --- AUTO_INCREMENT dla tabeli `users` --- -ALTER TABLE `users` - MODIFY `user_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=5; - -- -- Ograniczenia dla zrzutów tabel -- @@ -349,7 +274,7 @@ ALTER TABLE `exams` -- ALTER TABLE `exams_groups` ADD CONSTRAINT `exams_groups_ibfk_1` FOREIGN KEY (`exam_id`) REFERENCES `exams` (`exam_id`) ON DELETE CASCADE, - ADD CONSTRAINT `exams_groups_ibfk_2` FOREIGN KEY (`group_id`) REFERENCES `groups` (`group_id`) ON DELETE CASCADE; + ADD CONSTRAINT `exams_groups_ibfk_2` FOREIGN KEY (`group_id`) REFERENCES `student_groups` (`group_id`) ON DELETE CASCADE; -- -- Ograniczenia dla tabeli `otp_codes` diff --git a/logs/app.log b/logs/app.log deleted file mode 100644 index e69de29..0000000 diff --git a/pom.xml b/pom.xml index 1ab2d6a..169e06e 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.3 + 3.5.5 org.pkwmtt @@ -28,6 +28,12 @@ PKWM Mobile App Team https://github.com/PKTTTeam + + Patryk Mazurek + https://github.com/PatMaz999 + PKWM Mobile App Team + https://github.com/PKTTTeam + @@ -69,14 +75,6 @@ lombok true - - - - com.h2database - h2 - runtime - - org.springframework.boot @@ -87,22 +85,40 @@ spring-security-test test + + io.jsonwebtoken + jjwt-api + 0.12.7 + + + io.jsonwebtoken + jjwt-impl + 0.12.7 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.7 + runtime + junit junit 4.13.1 - - org.mockito - mockito-all - 1.10.19 - org.mockito mockito-core 5.18.0 + + + com.h2database + h2 + test + @@ -126,7 +142,7 @@ org.springdoc springdoc-openapi-starter-webmvc-ui - 2.8.9 + 2.8.12 @@ -138,6 +154,11 @@ org.springframework.boot spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-validation + ch.qos.logback @@ -147,7 +168,7 @@ org.wiremock.integrations wiremock-spring-boot - 3.10.0 + 3.10.6 @@ -160,6 +181,17 @@ dotenv-java 3.0.0 + + + com.icegreen + greenmail-junit5 + 2.0.0 + test + + + org.eclipse.angus + angus-activation + diff --git a/src/main/java/org/pkwmtt/config/StartupConfig.java b/src/main/java/org/pkwmtt/config/StartupConfig.java deleted file mode 100644 index 234dee7..0000000 --- a/src/main/java/org/pkwmtt/config/StartupConfig.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.pkwmtt.config; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - - -/** - * Logs server base url so you can click on it after start and - * go directly to swagger - */ -@Slf4j -@Component -public class StartupConfig { - - @Value("${server.port:}") - String port = ""; - - @Value("${server.address:}") - String address = ""; - - @EventListener(ContextRefreshedEvent.class) - public void onApplicationEvent() { - try { - if (port.isEmpty() || address.isEmpty()) - throw new Exception(); - log.info("SERVER URL: http://{}:{}", address, port); - } catch (Exception e) { - log.error("!Couldn't log the server base url. Check properties in application.properties"); - } - } - -} diff --git a/src/main/java/org/pkwmtt/entity/Exam.java b/src/main/java/org/pkwmtt/entity/Exam.java deleted file mode 100644 index 6cda359..0000000 --- a/src/main/java/org/pkwmtt/entity/Exam.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.pkwmtt.entity; - -import jakarta.persistence.*; -import lombok.Data; -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; - -@Entity -@Table(name = "`exams`") -@Data -public class Exam { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "exam_id") - private Integer examId; - - @Column(nullable = false) - private String title; - - private String description; - - @Column(name = "`exam_date`", nullable = false) - private LocalDateTime examDate; - - @ManyToOne - @JoinColumn(name = "exam_type_id", nullable = false) - private ExamType examType; - - @ManyToMany - @JoinTable( - name="exams_groups", - joinColumns = @JoinColumn(name = "exam_id"), - inverseJoinColumns = @JoinColumn(name = "group_id") - ) - private Set groups = new HashSet<>();; - -} diff --git a/src/main/java/org/pkwmtt/entity/OTPCode.java b/src/main/java/org/pkwmtt/entity/OTPCode.java deleted file mode 100644 index a202b42..0000000 --- a/src/main/java/org/pkwmtt/entity/OTPCode.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.pkwmtt.entity; - -import jakarta.persistence.*; -import lombok.Data; -import java.time.LocalDateTime; - -@Entity -@Data -@Table(name = "otp_codes") -public class OTPCode { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "otp_code_id") - private Integer otpCodeId; - - @Column(nullable = false) - private String code; - - @Column(nullable = false) - private LocalDateTime expire; - - @OneToOne - @JoinColumn(name = "`general_group_id`", nullable = false) - private GeneralGroup generalGroup; -} diff --git a/src/main/java/org/pkwmtt/entity/StudentGroup.java b/src/main/java/org/pkwmtt/entity/StudentGroup.java deleted file mode 100644 index 5c0f10c..0000000 --- a/src/main/java/org/pkwmtt/entity/StudentGroup.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.pkwmtt.entity; - -import jakarta.persistence.*; -import lombok.Data; - -import java.util.HashSet; -import java.util.Set; - -@Entity -@Data -@Table(name = "`groups`") -public class StudentGroup { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "group_id") - private Integer groupId; - - @Column(nullable = false) - private String name; - - @ManyToMany(mappedBy = "groups") - private Set exams = new HashSet<>(); -} diff --git a/src/main/java/org/pkwmtt/examCalendar/ExamController.java b/src/main/java/org/pkwmtt/examCalendar/ExamController.java new file mode 100644 index 0000000..8e7ebd0 --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/ExamController.java @@ -0,0 +1,94 @@ +package org.pkwmtt.examCalendar; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.examCalendar.dto.ExamDto; +import org.pkwmtt.examCalendar.entity.Exam; +import org.pkwmtt.examCalendar.entity.ExamType; +import org.pkwmtt.examCalendar.mapper.ExamDtoMapper; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.List; +import java.util.Set; + +@Validated +@RequiredArgsConstructor +@RequestMapping("${apiPrefix}/exams") +@RestController +public class ExamController { + + private final ExamService examService; + + /** + * @param examDto details of exam + * @return 201 created with URI to GET method which returns created resource + */ + @PostMapping("") + public ResponseEntity addExam(@RequestBody @Valid ExamDto examDto){ + int id = examService.addExam(examDto); + URI uri = ServletUriComponentsBuilder + .fromCurrentRequest() + .path("/{id}") + .buildAndExpand(id) + .toUri(); + return ResponseEntity.created(uri).build(); + } + + /** + * @param id of exam or test + * @param examDto new details of exam or test + * @return 204 no content + */ + @PutMapping("/{id}") + public ResponseEntity modifyExam(@PathVariable @Positive int id, @RequestBody @Valid ExamDto examDto) { + examService.modifyExam(examDto, id); + return ResponseEntity.noContent().build(); + } + + /** + * @param id of exam or test + * @return 204 no content + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteExam(@PathVariable int id) { + examService.deleteExam(id); + return ResponseEntity.noContent().build(); + } + + /** + * @param id of exam or test + * @return 200 ok with single exam or test details + */ + @GetMapping("/{id}") + public ResponseEntity getExam(@PathVariable int id) { + return ResponseEntity.ok(examService.getExamById(id)); + } + + /** + * when subgroups isn't null all generalGroups must be form the same year of study. e.g. 12K2, 12K1 is from 12K + * @param generalGroups set of general groups e.g. 12K2 + * @param subgroups set of subgroups of general group e.g. L04 + * @return List of ExamDto for specific groups + */ + @GetMapping("/by-groups") + public ResponseEntity> getExams( + @RequestParam Set generalGroups, + @RequestParam(required = false) Set subgroups + ){ + return ResponseEntity.ok(ExamDtoMapper.mapToExamDto(examService.getExamByGroups(generalGroups, subgroups))); + } + + /** + * @return 200 ok with list of available exam types + */ + @GetMapping("/exam-types") + public ResponseEntity> getExamTypes(){ + return ResponseEntity.ok(examService.getExamTypes()); + } + +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/examCalendar/ExamControllerAdvice.java b/src/main/java/org/pkwmtt/examCalendar/ExamControllerAdvice.java new file mode 100644 index 0000000..20a9e8b --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/ExamControllerAdvice.java @@ -0,0 +1,48 @@ +package org.pkwmtt.examCalendar; + +import jakarta.validation.ConstraintViolationException; +import org.pkwmtt.exceptions.*; +import org.pkwmtt.exceptions.dto.ErrorResponseDTO; +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; + +import java.util.stream.Collectors; + +@RestControllerAdvice(assignableTypes = {ExamController.class}) +public class ExamControllerAdvice { + + @ExceptionHandler(NoSuchElementWithProvidedIdException.class) + public ResponseEntity handleNoSuchElementWithProvidedIdException(NoSuchElementWithProvidedIdException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponseDTO(e.getMessage())); + } + + @ExceptionHandler({ + ExamTypeNotExistsException.class, + InvalidGroupIdentifierException.class, + SpecifiedGeneralGroupDoesntExistsException.class, + SpecifiedSubGroupDoesntExistsException.class, + UnsupportedCountOfArgumentsException.class + }) + public ResponseEntity handleBadRequest(RuntimeException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponseDTO(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(field -> field.getField() + " : " + field.getDefaultMessage()) + .collect(Collectors.joining(", ")); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponseDTO(message)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException e) { + String message = e.getConstraintViolations().stream() + .map(field -> field.getPropertyPath() + " : " + field.getMessage()) + .collect(Collectors.joining(", ")); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponseDTO(message)); + } +} diff --git a/src/main/java/org/pkwmtt/examCalendar/ExamService.java b/src/main/java/org/pkwmtt/examCalendar/ExamService.java new file mode 100644 index 0000000..58621b0 --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/ExamService.java @@ -0,0 +1,227 @@ +package org.pkwmtt.examCalendar; + +import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.examCalendar.dto.ExamDto; +import org.pkwmtt.examCalendar.entity.Exam; +import org.pkwmtt.examCalendar.entity.ExamType; +import org.pkwmtt.examCalendar.entity.StudentGroup; +import org.pkwmtt.examCalendar.mapper.ExamDtoMapper; +import org.pkwmtt.examCalendar.repository.ExamRepository; +import org.pkwmtt.examCalendar.repository.ExamTypeRepository; +import org.pkwmtt.examCalendar.repository.GroupRepository; +import org.pkwmtt.exceptions.*; +import org.pkwmtt.timetable.TimetableService; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional +public class ExamService { + + private final ExamRepository examRepository; + private final ExamTypeRepository examTypeRepository; + private final GroupRepository groupRepository; + private final TimetableService timetableService; + + /** + * @param examDto details of exam + * @return id of exam added to database + */ + public int addExam(ExamDto examDto) { + + Set groups = verifyAndUpdateExamGroups(examDto); + +// check if exam type exists + ExamType examType = examTypeRepository.findByName(examDto.getExamType()) + .orElseThrow(() -> new ExamTypeNotExistsException(examDto.getExamType())); + +// save exam in repository and return id of created exam + return examRepository.save(ExamDtoMapper.mapToNewExam(examDto, groups, examType)).getExamId(); + } + + /** + * @param examDto new details of exam that overwrite old ones + * @param id of exam that need to be modified + */ + public void modifyExam(ExamDto examDto, int id) { +// check if exam which would be modified exists + examRepository.findById(id).orElseThrow(() -> new NoSuchElementWithProvidedIdException(id)); + + Set groups = verifyAndUpdateExamGroups(examDto); + +// check if exam type exists + ExamType examType = examTypeRepository.findByName(examDto.getExamType()) + .orElseThrow(() -> new ExamTypeNotExistsException(examDto.getExamType())); + + examRepository.save(ExamDtoMapper.mapToExistingExam(examDto, groups, examType, id)); + } + + /** + * @param id of exam + */ + public void deleteExam(int id) { + examRepository.findById(id).orElseThrow(() -> new NoSuchElementWithProvidedIdException(id)); + examRepository.deleteById(id); + } + + /** + * @param id of exam + * @return exam + */ + public Exam getExamById(int id) { + return examRepository.findById(id).orElseThrow(() -> new NoSuchElementWithProvidedIdException(id)); + } + + public Set getExamByGroups(Set generalGroups, Set subgroups) { +// verify generalGroups identifiers + verifyGeneralGroupsFormat(generalGroups); +// get exams for general groups + Set exams = new HashSet<>(examRepository.findAllByGroups_NameIn(generalGroups)); + exams = exams.stream() + .filter(exam -> exam.getGroups().stream() + .allMatch(group -> group.getName().matches("^\\d.*"))) + .collect(Collectors.toSet()); + +// convert general group identifiers. e.g. 12K2 to 12K + Set superiorGroups = generalGroups.stream().map(g -> { + if (Character.isDigit(g.charAt(g.length() - 1))) + return g.substring(0, g.length() - 1); + return g; + }).collect(Collectors.toSet()); +// check if subgroups are provided + if (subgroups != null && !subgroups.isEmpty()) { +// verify subgroups identifiers + verifySubgroupsFormat(subgroups); +// check if superior group identifies the groups unambiguously + if (superiorGroups.size() != 1) + throw new InvalidGroupIdentifierException("ambiguous superior group identifier for subgroups"); + exams.addAll(examRepository.findAllBySubgroupsOfGeneralGroup(superiorGroups.iterator().next(), subgroups)); + } + return exams; + } + + /** + * @return list of examTypes + */ + public List getExamTypes() { + return examTypeRepository.findAll(); + } + + /** + * verify if groups exists in timetable if exist updates database. + * when timetable service is unavailable verifies groups using groupsRepository + * + * @param examDto containing groups for verification + */ + private Set verifyAndUpdateExamGroups(ExamDto examDto) { + Set generalGroupsFromRepository; + Set generalGroups = examDto.getGeneralGroups(); + Set subgroups = examDto.getSubgroups(); +// if timetable service is unavailable verify general groups using GroupRepository + try { + generalGroupsFromRepository = new HashSet<>(timetableService.getGeneralGroupList()); + } catch (WebPageContentNotAvailableException e) { + generalGroupsFromRepository = verifyGroupsUsingRepository(generalGroups); + } +// verify generalGroups using timetable service + if (!generalGroupsFromRepository.containsAll(generalGroups)) { + generalGroups.removeAll(generalGroupsFromRepository); + throw new InvalidGroupIdentifierException(generalGroups); + } +// if there are no subgroups save exam for exercise groups or whole year e.g. +// 12K2 - exercise group exam +// 12K1, 12K2, 12K3 - whole year exam + if (subgroups == null || subgroups.isEmpty()) { + return saveNewStudentGroups(generalGroups); +// exams for subgroups e.g. L04 must have only superior group to avoid ambiguity + } else if (generalGroups.size() == 1) { +// if there are only one group change it from Set to String + String superiorGroup = generalGroups.iterator().next(); + Set subGroupsFromTimetable; + try { + subGroupsFromTimetable = new HashSet<>(timetableService.getAvailableSubGroups(superiorGroup)); + } catch (JsonProcessingException | + SpecifiedGeneralGroupDoesntExistsException | + WebPageContentNotAvailableException e) { + throw new ServiceNotAvailableException("Couldn't verify groups using timetable service"); +// TODO: add verification with repository when timetable service is unavailable + } +// verify if subgroups for specific general group exists + if (!subGroupsFromTimetable.containsAll(subgroups)) { + subgroups.removeAll(subGroupsFromTimetable); + throw new InvalidGroupIdentifierException(subgroups); + } +// change superior group format e.g. 12K2 to 12K + if (Character.isDigit(superiorGroup.charAt(superiorGroup.length() - 1))) + superiorGroup = superiorGroup.substring(0, superiorGroup.length() - 1); +// save subgroups with superior group identifier + subgroups.add(superiorGroup); + return saveNewStudentGroups(subgroups); + } +// only one general group could be assigned to subgroups (when there are more than 1 general group and +// more than 0 subgroups) + else if (generalGroups.isEmpty()) + throw new InvalidGroupIdentifierException("general group is missing"); + else + throw new InvalidGroupIdentifierException("ambiguous general groups for subgroups"); + } + + /** + * @param groups groups that would be verified using repository + * @return set of groups (String) when verification succeeded + * @throws WebPageContentNotAvailableException when verification not succeeded + */ + private Set verifyGroupsUsingRepository(Set groups) throws WebPageContentNotAvailableException { + verifyGeneralGroupsFormat(groups); + Set groupsFromRepository = groupRepository.findAllByNameIn(groups).stream() + .map(StudentGroup::getName) + .collect(Collectors.toSet() + ); + if (groupsFromRepository.containsAll(groups)) + return groups; + else + throw new ServiceNotAvailableException("Couldn't verify groups using repository"); + } + + /** + * saves groups to groupRepository, existing group names are filtered out before saving + * + * @param groups groups that would be saved to repository + * @return set of StudentsGroup Entity with names from groups. + */ + private Set saveNewStudentGroups(Set groups) { +// remove duplicates before saving records + Set existingGroups = groupRepository.findAllByNameIn(groups); + groups.removeAll(existingGroups.stream() + .map(StudentGroup::getName) + .collect(Collectors.toSet()) + ); + List savedGroups = groupRepository.saveAll(groups.stream() + .map(g -> StudentGroup.builder() + .name(g) + .build() + ).collect(Collectors.toList()) + ); + existingGroups.addAll(savedGroups); + return existingGroups; + } + + private static void verifyGeneralGroupsFormat(Set generalGroups) throws SpecifiedGeneralGroupDoesntExistsException { + generalGroups.forEach(group -> { + if (!group.matches("^\\d.*")) + throw new SpecifiedGeneralGroupDoesntExistsException(group); + }); + } + + private static void verifySubgroupsFormat(Set subgroups) { + subgroups.forEach(group -> { + if (!group.matches("^[A-Z].*")) + throw new SpecifiedSubGroupDoesntExistsException(group); + }); + } +} diff --git a/src/main/java/org/pkwmtt/examCalendar/dto/ExamDto.java b/src/main/java/org/pkwmtt/examCalendar/dto/ExamDto.java new file mode 100644 index 0000000..8b5e72a --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/dto/ExamDto.java @@ -0,0 +1,36 @@ +package org.pkwmtt.examCalendar.dto; + +import jakarta.validation.constraints.*; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.examCalendar.entity.StudentGroup; + +import java.time.LocalDateTime; +import java.util.Set; + +@Getter +@RequiredArgsConstructor +@Builder +public class ExamDto { + + @NotBlank + @Size(max = 255, message = "max size of field is 255") + private final String title; + + @Size(max = 255, message = "max size of field is 255") + private final String description; + + @Future(message = "Date must be in the future") + @NotNull + private final LocalDateTime date; + + @NotNull + private final String examType; + + @NotEmpty + @Size(min = 1) + private final Set generalGroups; + + private final Set subgroups; +} diff --git a/src/main/java/org/pkwmtt/examCalendar/entity/Exam.java b/src/main/java/org/pkwmtt/examCalendar/entity/Exam.java new file mode 100644 index 0000000..31d115a --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/entity/Exam.java @@ -0,0 +1,81 @@ +package org.pkwmtt.examCalendar.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.exceptions.UnsupportedCountOfArgumentsException; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +@Entity +@Getter +@Builder(builderClassName = "Builder") +@RequiredArgsConstructor +@Table(name = "exams") +@AllArgsConstructor +public class Exam { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "exam_id") + private Integer examId; + + @Column(nullable = false) + private String title; + + private String description; + + @Column(name = "exam_date", nullable = false) + private LocalDateTime examDate; + + @ManyToOne + @JoinColumn(name = "exam_type_id", nullable = false) + private ExamType examType; + + @ManyToMany + @JoinTable( + name="exams_groups", + joinColumns = @JoinColumn(name = "exam_id"), + inverseJoinColumns = @JoinColumn(name = "group_id") + ) + private Set groups = new HashSet<>(); + + @SuppressWarnings("unused") + public static class Builder { + public Exam build() { +// min 1 max 100 elements of set + if(groups == null) + throw new UnsupportedCountOfArgumentsException(1, 100, null); + if(groups.isEmpty() || groups.size() > 100) + throw new UnsupportedCountOfArgumentsException(1, 100, groups.size()); + return new Exam(examId, title, description, examDate, examType, groups); + } + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + + Exam exam = (Exam) o; + return getTitle().equals(exam.getTitle()) && + Objects.equals(getDescription(), exam.getDescription()) && + getExamDate().truncatedTo(ChronoUnit.MINUTES).equals(exam.getExamDate().truncatedTo(ChronoUnit.MINUTES)) && + getExamType().equals(exam.getExamType()) && + getGroups().equals(exam.getGroups()); + } + + @Override + public int hashCode() { + int result = getTitle().hashCode(); + result = 31 * result + Objects.hashCode(getDescription()); + result = 31 * result + getExamDate().truncatedTo(ChronoUnit.MINUTES).hashCode(); + result = 31 * result + getExamType().hashCode(); + result = 31 * result + getGroups().hashCode(); + return result; + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/entity/ExamType.java b/src/main/java/org/pkwmtt/examCalendar/entity/ExamType.java similarity index 52% rename from src/main/java/org/pkwmtt/entity/ExamType.java rename to src/main/java/org/pkwmtt/examCalendar/entity/ExamType.java index fd971ef..90a9f74 100644 --- a/src/main/java/org/pkwmtt/entity/ExamType.java +++ b/src/main/java/org/pkwmtt/examCalendar/entity/ExamType.java @@ -1,11 +1,17 @@ -package org.pkwmtt.entity; +package org.pkwmtt.examCalendar.entity; import jakarta.persistence.*; -import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; @Entity -@Data -@Table(name = "`exam_type`") +@Getter +@Builder +@AllArgsConstructor +@RequiredArgsConstructor +@Table(name = "exam_type") public class ExamType { @Id @@ -15,4 +21,4 @@ public class ExamType { @Column(nullable = false) private String name; -} +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/entity/GeneralGroup.java b/src/main/java/org/pkwmtt/examCalendar/entity/GeneralGroup.java similarity index 52% rename from src/main/java/org/pkwmtt/entity/GeneralGroup.java rename to src/main/java/org/pkwmtt/examCalendar/entity/GeneralGroup.java index eb00158..c32e545 100644 --- a/src/main/java/org/pkwmtt/entity/GeneralGroup.java +++ b/src/main/java/org/pkwmtt/examCalendar/entity/GeneralGroup.java @@ -1,11 +1,17 @@ -package org.pkwmtt.entity; +package org.pkwmtt.examCalendar.entity; import jakarta.persistence.*; -import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity -@Data -@Table(name = "`general_group`") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "general_group") public class GeneralGroup { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/org/pkwmtt/examCalendar/entity/OTPCode.java b/src/main/java/org/pkwmtt/examCalendar/entity/OTPCode.java new file mode 100644 index 0000000..2694908 --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/entity/OTPCode.java @@ -0,0 +1,38 @@ +package org.pkwmtt.examCalendar.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "otp_codes") +public class OTPCode { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "otp_code_id") + private Integer otpCodeId; + + @Column(nullable = false) + private String code; + + @Column(nullable = false) + private LocalDateTime expire; + + @OneToOne + @JoinColumn(name = "general_group_id", nullable = false) + private GeneralGroup generalGroup; + + public OTPCode (String code, GeneralGroup generalGroup) { + this.code = code; + this.generalGroup = generalGroup; + this.expire = LocalDateTime.now().plusDays(1); + } +} diff --git a/src/main/java/org/pkwmtt/examCalendar/entity/StudentGroup.java b/src/main/java/org/pkwmtt/examCalendar/entity/StudentGroup.java new file mode 100644 index 0000000..158cce6 --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/entity/StudentGroup.java @@ -0,0 +1,21 @@ +package org.pkwmtt.examCalendar.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@RequiredArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "student_groups") +public class StudentGroup { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "group_id") + private Integer groupId; + + @Column(nullable = false, unique = true) + private String name; +} diff --git a/src/main/java/org/pkwmtt/entity/User.java b/src/main/java/org/pkwmtt/examCalendar/entity/User.java similarity index 68% rename from src/main/java/org/pkwmtt/entity/User.java rename to src/main/java/org/pkwmtt/examCalendar/entity/User.java index 81a03c8..4cdfbc9 100644 --- a/src/main/java/org/pkwmtt/entity/User.java +++ b/src/main/java/org/pkwmtt/examCalendar/entity/User.java @@ -1,11 +1,17 @@ -package org.pkwmtt.entity; +package org.pkwmtt.examCalendar.entity; import jakarta.persistence.*; -import lombok.Data; -import org.pkwmtt.enums.Role; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.pkwmtt.examCalendar.enums.Role; @Entity -@Data +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor @Table(name = "`users`") public class User { @Id diff --git a/src/main/java/org/pkwmtt/enums/Role.java b/src/main/java/org/pkwmtt/examCalendar/enums/Role.java similarity index 56% rename from src/main/java/org/pkwmtt/enums/Role.java rename to src/main/java/org/pkwmtt/examCalendar/enums/Role.java index 0a72aa0..aafdf12 100644 --- a/src/main/java/org/pkwmtt/enums/Role.java +++ b/src/main/java/org/pkwmtt/examCalendar/enums/Role.java @@ -1,4 +1,4 @@ -package org.pkwmtt.enums; +package org.pkwmtt.examCalendar.enums; public enum Role { ADMIN, diff --git a/src/main/java/org/pkwmtt/enums/SubjectType.java b/src/main/java/org/pkwmtt/examCalendar/enums/SubjectType.java similarity index 77% rename from src/main/java/org/pkwmtt/enums/SubjectType.java rename to src/main/java/org/pkwmtt/examCalendar/enums/SubjectType.java index f43282e..0aad3d5 100644 --- a/src/main/java/org/pkwmtt/enums/SubjectType.java +++ b/src/main/java/org/pkwmtt/examCalendar/enums/SubjectType.java @@ -1,4 +1,4 @@ -package org.pkwmtt.enums; +package org.pkwmtt.examCalendar.enums; public enum SubjectType { LECTURE, diff --git a/src/main/java/org/pkwmtt/examCalendar/mapper/ExamDtoMapper.java b/src/main/java/org/pkwmtt/examCalendar/mapper/ExamDtoMapper.java new file mode 100644 index 0000000..cf3c3aa --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/mapper/ExamDtoMapper.java @@ -0,0 +1,73 @@ +package org.pkwmtt.examCalendar.mapper; + +import lombok.extern.slf4j.Slf4j; +import org.pkwmtt.examCalendar.dto.ExamDto; +import org.pkwmtt.examCalendar.entity.Exam; +import org.pkwmtt.examCalendar.entity.ExamType; +import org.pkwmtt.examCalendar.entity.StudentGroup; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * maps ExamDto to Exam entity. Couldn't be utility class, because needs ExamTypeRepository to validate exam types + */ +@Slf4j +public class ExamDtoMapper { + private ExamDtoMapper() { + throw new IllegalStateException("Utility class"); + } + + /** + * @param examDto examDto object received from request + * @return Exam entity WITHOUT examId which should be assigned by database + * Also contains examType field converted from String do ExamType + */ + public static Exam mapToNewExam(ExamDto examDto, Set groups, ExamType examType) { + return Exam.builder() + .title(examDto.getTitle()) + .description(examDto.getDescription()) + .examDate(examDto.getDate()) + .examType(examType) + .groups(groups) + .build(); + } + + /** + * @param examDto examDto object received from request + * @param id of Exam that need to be modified + * @return Exam entity WITH examId that allow to update entity in database instead of creating new one + * Also contains examType field converted from String do ExamType + */ + public static Exam mapToExistingExam(ExamDto examDto, Set groups, ExamType examType, int id) { + return Exam.builder() + .examId(id) + .title(examDto.getTitle()) + .description(examDto.getDescription()) + .examDate(examDto.getDate()) + .examType(examType) + .groups(groups) + .build(); + } + + public static List mapToExamDto(Set exams) { + return exams.stream().map(ExamDtoMapper::mapToExamDto).collect(Collectors.toList()); + } + + public static ExamDto mapToExamDto(Exam exam) { + Set groups = exam.getGroups().stream().map(StudentGroup::getName).collect(Collectors.toSet()); + Set generalGroups = groups.stream().filter(group -> Character.isDigit(group.charAt(0))).collect(Collectors.toSet()); + Set subgroups = groups.stream().filter(group -> Character.isAlphabetic(group.charAt(0))).collect(Collectors.toSet()); + if(groups.size() != subgroups.size() + generalGroups.size()) + log.warn("Some groups of {} were not consumed in ExamDtoMapper.mapToExamDto()", groups); + return ExamDto.builder() + .title(exam.getTitle()) + .description(exam.getDescription()) + .date(exam.getExamDate()) + .examType(exam.getExamType().getName()) + .generalGroups(generalGroups) + .subgroups(subgroups) + .build(); + } +} diff --git a/src/main/java/org/pkwmtt/examCalendar/repository/ExamRepository.java b/src/main/java/org/pkwmtt/examCalendar/repository/ExamRepository.java new file mode 100644 index 0000000..c87b283 --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/repository/ExamRepository.java @@ -0,0 +1,31 @@ +package org.pkwmtt.examCalendar.repository; + +import org.pkwmtt.examCalendar.entity.Exam; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Set; + +public interface ExamRepository extends JpaRepository { + + /** + * @param groups set of generalGroups + * @return list of exams for generalGroups + */ + List findAllByGroups_NameIn(Set groups); + + /** + * @param generalGroup superior group of subgroups e.g. 12K + * @param subgroup exam groups + * @return list of exams for subgroups + */ + @Query(""" + SELECT DISTINCT e FROM Exam e + JOIN e.groups g1 + JOIN FETCH e.groups g2 + WHERE g1.name = :general AND g2.name IN :sub + """) + Set findAllBySubgroupsOfGeneralGroup(@Param("general") String generalGroup, @Param("sub") Set subgroup); +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/examCalendar/repository/ExamTypeRepository.java b/src/main/java/org/pkwmtt/examCalendar/repository/ExamTypeRepository.java new file mode 100644 index 0000000..c14d733 --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/repository/ExamTypeRepository.java @@ -0,0 +1,10 @@ +package org.pkwmtt.examCalendar.repository; + +import org.pkwmtt.examCalendar.entity.ExamType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ExamTypeRepository extends JpaRepository { + Optional findByName(String name); +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/examCalendar/repository/GeneralGroupRepository.java b/src/main/java/org/pkwmtt/examCalendar/repository/GeneralGroupRepository.java new file mode 100644 index 0000000..fa787aa --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/repository/GeneralGroupRepository.java @@ -0,0 +1,10 @@ +package org.pkwmtt.examCalendar.repository; + +import org.pkwmtt.examCalendar.entity.GeneralGroup; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface GeneralGroupRepository extends JpaRepository { + Optional findByName (String generalGroupName); +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/examCalendar/repository/GroupRepository.java b/src/main/java/org/pkwmtt/examCalendar/repository/GroupRepository.java new file mode 100644 index 0000000..7a9e4dd --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/repository/GroupRepository.java @@ -0,0 +1,10 @@ +package org.pkwmtt.examCalendar.repository; + +import org.pkwmtt.examCalendar.entity.StudentGroup; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Set; + +public interface GroupRepository extends JpaRepository { + Set findAllByNameIn(Set names); +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/examCalendar/repository/UserRepository.java b/src/main/java/org/pkwmtt/examCalendar/repository/UserRepository.java new file mode 100644 index 0000000..3a195fa --- /dev/null +++ b/src/main/java/org/pkwmtt/examCalendar/repository/UserRepository.java @@ -0,0 +1,13 @@ +package org.pkwmtt.examCalendar.repository; + +import org.pkwmtt.examCalendar.entity.GeneralGroup; +import org.pkwmtt.examCalendar.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail (String email); + + Optional findByGeneralGroup (GeneralGroup generalGroup); +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/exceptions/ExamTypeNotExistsException.java b/src/main/java/org/pkwmtt/exceptions/ExamTypeNotExistsException.java new file mode 100644 index 0000000..5e8171a --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/ExamTypeNotExistsException.java @@ -0,0 +1,7 @@ +package org.pkwmtt.exceptions; + +public class ExamTypeNotExistsException extends RuntimeException { + public ExamTypeNotExistsException(String examType) { + super("Invalid exam type " + examType); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/IncorrectApiKeyValue.java b/src/main/java/org/pkwmtt/exceptions/IncorrectApiKeyValue.java new file mode 100644 index 0000000..873c00a --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/IncorrectApiKeyValue.java @@ -0,0 +1,9 @@ +package org.pkwmtt.exceptions; + +import com.mysql.cj.exceptions.WrongArgumentException; + +public class IncorrectApiKeyValue extends WrongArgumentException { + public IncorrectApiKeyValue () { + super("API Key authentication unsuccessful"); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/InvalidGroupIdentifierException.java b/src/main/java/org/pkwmtt/exceptions/InvalidGroupIdentifierException.java new file mode 100644 index 0000000..a47bf28 --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/InvalidGroupIdentifierException.java @@ -0,0 +1,13 @@ +package org.pkwmtt.exceptions; + +import java.util.Set; + +public class InvalidGroupIdentifierException extends RuntimeException { + public InvalidGroupIdentifierException(String groupIdentifier) { + super("Invalid group identifier: " + groupIdentifier); + } + + public InvalidGroupIdentifierException(Set groupIdentifiers) { + super("Invalid group identifiers: " + groupIdentifiers.toString()); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/MailCouldNotBeSendException.java b/src/main/java/org/pkwmtt/exceptions/MailCouldNotBeSendException.java new file mode 100644 index 0000000..989a843 --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/MailCouldNotBeSendException.java @@ -0,0 +1,7 @@ +package org.pkwmtt.exceptions; + +public class MailCouldNotBeSendException extends RuntimeException { + public MailCouldNotBeSendException (String message) { + super(message); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/MissingHeaderException.java b/src/main/java/org/pkwmtt/exceptions/MissingHeaderException.java new file mode 100644 index 0000000..500cd0a --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/MissingHeaderException.java @@ -0,0 +1,7 @@ +package org.pkwmtt.exceptions; + +public class MissingHeaderException extends Exception { + public MissingHeaderException (String headerName) { + super(String.format("Missing header: [%s]", headerName)); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/NoSuchElementWithProvidedIdException.java b/src/main/java/org/pkwmtt/exceptions/NoSuchElementWithProvidedIdException.java new file mode 100644 index 0000000..e17eead --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/NoSuchElementWithProvidedIdException.java @@ -0,0 +1,7 @@ +package org.pkwmtt.exceptions; + +public class NoSuchElementWithProvidedIdException extends RuntimeException{ + public NoSuchElementWithProvidedIdException(int id) { + super("No such element with id: " + id); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/OTPCodeNotFoundException.java b/src/main/java/org/pkwmtt/exceptions/OTPCodeNotFoundException.java new file mode 100644 index 0000000..2626ec8 --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/OTPCodeNotFoundException.java @@ -0,0 +1,8 @@ +package org.pkwmtt.exceptions; + +public class OTPCodeNotFoundException + extends IllegalArgumentException { + public OTPCodeNotFoundException () { + super("Provided isn't assigned to any group."); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/ServiceNotAvailableException.java b/src/main/java/org/pkwmtt/exceptions/ServiceNotAvailableException.java new file mode 100644 index 0000000..2f8b1b2 --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/ServiceNotAvailableException.java @@ -0,0 +1,7 @@ +package org.pkwmtt.exceptions; + +public class ServiceNotAvailableException extends RuntimeException { + public ServiceNotAvailableException(String message) { + super(message); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/UnsupportedCountOfArgumentsException.java b/src/main/java/org/pkwmtt/exceptions/UnsupportedCountOfArgumentsException.java new file mode 100644 index 0000000..fc3718c --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/UnsupportedCountOfArgumentsException.java @@ -0,0 +1,8 @@ +package org.pkwmtt.exceptions; + +public class UnsupportedCountOfArgumentsException extends RuntimeException { + public UnsupportedCountOfArgumentsException(int expectedMin, int expectedMax, Integer provided) { + super("Invalid count of arguments provided: " + provided + + " expected more than: " + expectedMin + " less than: " + expectedMax); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/UserNotFoundException.java b/src/main/java/org/pkwmtt/exceptions/UserNotFoundException.java new file mode 100644 index 0000000..72400d3 --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/UserNotFoundException.java @@ -0,0 +1,8 @@ +package org.pkwmtt.exceptions; + +public class UserNotFoundException + extends IllegalArgumentException { + public UserNotFoundException(String message){ + super(message); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/WrongOTPFormatException.java b/src/main/java/org/pkwmtt/exceptions/WrongOTPFormatException.java new file mode 100644 index 0000000..414d347 --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/WrongOTPFormatException.java @@ -0,0 +1,8 @@ +package org.pkwmtt.exceptions; + +public class WrongOTPFormatException + extends IllegalArgumentException { + public WrongOTPFormatException (String message) { + super(message); + } +} diff --git a/src/main/java/org/pkwmtt/global/GlobalExceptionHandler.java b/src/main/java/org/pkwmtt/global/GlobalExceptionHandler.java new file mode 100644 index 0000000..5438a5e --- /dev/null +++ b/src/main/java/org/pkwmtt/global/GlobalExceptionHandler.java @@ -0,0 +1,23 @@ +package org.pkwmtt.global; + +import org.apache.logging.log4j.util.InternalException; +import org.pkwmtt.exceptions.IncorrectApiKeyValue; +import org.pkwmtt.exceptions.dto.ErrorResponseDTO; +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(IncorrectApiKeyValue.class) + public ResponseEntity handleIncorrectApiKeyValue (Exception e) { + return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(InternalException.class) + public ResponseEntity handleInternalException (Exception e) { + return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/org/pkwmtt/global/RequestInterceptor.java b/src/main/java/org/pkwmtt/global/RequestInterceptor.java new file mode 100644 index 0000000..45de179 --- /dev/null +++ b/src/main/java/org/pkwmtt/global/RequestInterceptor.java @@ -0,0 +1,47 @@ +package org.pkwmtt.global; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.InternalException; +import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.exceptions.IncorrectApiKeyValue; +import org.pkwmtt.exceptions.MissingHeaderException; +import org.pkwmtt.security.apiKey.ApiKeyService; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Slf4j +@Component +@RequiredArgsConstructor +@Profile("!test & !database") //Skip on tests +public class RequestInterceptor implements HandlerInterceptor { + + private final ApiKeyService apiKeyService; + + @Override + public boolean preHandle (@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) { + + + String headerName = "X-API-KEY"; + try { + String providedApiKey = request.getHeader(headerName); + + if (providedApiKey == null || providedApiKey.isBlank()) { + throw new MissingHeaderException(headerName); + } + + apiKeyService.validateApiKey(providedApiKey, Role.REPRESENTATIVE); + } catch (IncorrectApiKeyValue | MissingHeaderException e) { + throw new IncorrectApiKeyValue(); + } catch (Exception e) { + log.error(e.getMessage()); + throw new InternalException("Internal server error with validating API key."); + } + + return true; + } +} diff --git a/src/main/java/org/pkwmtt/config/GlobalCorsConfig.java b/src/main/java/org/pkwmtt/global/config/GlobalCorsConfig.java similarity index 89% rename from src/main/java/org/pkwmtt/config/GlobalCorsConfig.java rename to src/main/java/org/pkwmtt/global/config/GlobalCorsConfig.java index 78a2e01..d05da52 100644 --- a/src/main/java/org/pkwmtt/config/GlobalCorsConfig.java +++ b/src/main/java/org/pkwmtt/global/config/GlobalCorsConfig.java @@ -1,4 +1,4 @@ -package org.pkwmtt.config; +package org.pkwmtt.global.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -23,8 +23,7 @@ public WebMvcConfigurer corsConfigurer() { @Override public void addCorsMappings(@NonNull CorsRegistry registry) { registry.addMapping("/pkmwtt/api/**") -// TODO: change host - .allowedOrigins("http://localhost:5173") + .allowedOrigins("https://pkwmapp.pl") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") // required for authorization and cookies, default false diff --git a/src/main/java/org/pkwmtt/config/HighlightingCompositeLogConverter.java b/src/main/java/org/pkwmtt/global/config/HighlightingCompositeLogConverter.java similarity index 95% rename from src/main/java/org/pkwmtt/config/HighlightingCompositeLogConverter.java rename to src/main/java/org/pkwmtt/global/config/HighlightingCompositeLogConverter.java index dc2440b..24bf38f 100644 --- a/src/main/java/org/pkwmtt/config/HighlightingCompositeLogConverter.java +++ b/src/main/java/org/pkwmtt/global/config/HighlightingCompositeLogConverter.java @@ -1,4 +1,4 @@ -package org.pkwmtt.config; +package org.pkwmtt.global.config; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.spi.ILoggingEvent; diff --git a/src/main/java/org/pkwmtt/global/config/SwaggerEndpointConfiguration.java b/src/main/java/org/pkwmtt/global/config/SwaggerEndpointConfiguration.java new file mode 100644 index 0000000..9263a2a --- /dev/null +++ b/src/main/java/org/pkwmtt/global/config/SwaggerEndpointConfiguration.java @@ -0,0 +1,66 @@ +package org.pkwmtt.global.config; + + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +@Configuration +@RequiredArgsConstructor +public class SwaggerEndpointConfiguration { + + private final Environment environment; + + //Add text field for api key to every request that need authentication with it + @Bean + public GroupedOpenApi publicEndpointCustomizer () { + String apiPrefix = environment.getProperty("apiPrefix", ""); + + return GroupedOpenApi.builder().group("all") // single group + .pathsToMatch(apiPrefix + "/**", "/admin/**").addOpenApiCustomizer(openApi -> { + Paths paths = openApi.getPaths(); + + paths.forEach((path, pathItem) -> pathItem.readOperations().forEach(operation -> { + if (path.startsWith("/admin")) { + addHeaderIfMissing( + operation, + "X-ADMIN-KEY", + "Admin API key", + "Admin-only endpoint", + "Requires X-ADMIN-KEY header", + "admin" + ); + } else if (path.startsWith(apiPrefix)) { + addHeaderIfMissing( + operation, + "X-API-KEY", + "Your API key", + "Public API endpoint", + "Requires X-API-KEY header", + "public" + ); + } + })); + }).build(); + } + + private void addHeaderIfMissing (Operation operation, String headerName, String headerDescription, String summary, String description, String tag) { + operation.setSummary(summary); + operation.setDescription(description); + operation.addTagsItem(tag); + operation.addParametersItem(new Parameter() + .name(headerName) + .in("header") + .required(true) + .description(headerDescription) + .schema(new StringSchema())); + } + + +} diff --git a/src/main/java/org/pkwmtt/global/config/WebConfig.java b/src/main/java/org/pkwmtt/global/config/WebConfig.java new file mode 100644 index 0000000..e900c37 --- /dev/null +++ b/src/main/java/org/pkwmtt/global/config/WebConfig.java @@ -0,0 +1,31 @@ +package org.pkwmtt.global.config; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.global.RequestInterceptor; +import org.pkwmtt.security.admin.AdminRequestInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.Optional; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + //During tests RequestInterceptor isn't required + private final Optional requestInterceptor; + private final AdminRequestInterceptor adminRequestInterceptor; + private final Environment environment; + + @Override + public void addInterceptors (@NonNull InterceptorRegistry registry) { + String apiPrefix = environment.getProperty("apiPrefix", ""); + requestInterceptor.ifPresent(interceptor -> registry + .addInterceptor(interceptor) + .addPathPatterns(apiPrefix + "/**")); + registry.addInterceptor(adminRequestInterceptor).addPathPatterns("/admin"); + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/mail/EmailService.java b/src/main/java/org/pkwmtt/mail/EmailService.java index 1174ec0..bdc3821 100644 --- a/src/main/java/org/pkwmtt/mail/EmailService.java +++ b/src/main/java/org/pkwmtt/mail/EmailService.java @@ -5,7 +5,6 @@ import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import org.pkwmtt.exceptions.MailServiceNotAvailableException; -import org.pkwmtt.mail.config.MailConfig; import org.pkwmtt.mail.dto.MailDTO; import org.springframework.core.env.Environment; import org.springframework.mail.javamail.JavaMailSender; @@ -27,10 +26,6 @@ private void assignProperties () { } public void send (MailDTO mail) throws MessagingException, MailServiceNotAvailableException { - if (!MailConfig.isEnabled()) { - throw new MailServiceNotAvailableException(); - } - MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); @@ -39,7 +34,6 @@ public void send (MailDTO mail) throws MessagingException, MailServiceNotAvailab helper.setTo(mail.getRecipient()); helper.setText(mail.getDescription(), true); helper.setSubject(mail.getTitle()); - mailSender.send(message); } diff --git a/src/main/java/org/pkwmtt/mail/EmailTempController.java b/src/main/java/org/pkwmtt/mail/EmailTempController.java deleted file mode 100644 index 1048a19..0000000 --- a/src/main/java/org/pkwmtt/mail/EmailTempController.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.pkwmtt.mail; - -import jakarta.mail.MessagingException; -import lombok.RequiredArgsConstructor; -import org.pkwmtt.exceptions.MailServiceNotAvailableException; -import org.pkwmtt.exceptions.dto.ErrorResponseDTO; -import org.pkwmtt.mail.dto.MailDTO; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/mail") -public class EmailTempController { - - private final EmailService service; - - @PostMapping - public void sendMail (@RequestParam(name = "r") String recipientEmailAddress) - throws MessagingException, MailServiceNotAvailableException { - service.send(new MailDTO() - .setRecipient(recipientEmailAddress) - .setDescription("TEST") - .setTitle("TEST")); - } - - @ExceptionHandler(MailServiceNotAvailableException.class) - public ResponseEntity handle (Exception e) { - return new ResponseEntity<>( - new ErrorResponseDTO(e.getMessage()), - HttpStatus.SERVICE_UNAVAILABLE - ); - } -} diff --git a/src/main/java/org/pkwmtt/mail/config/MailConfig.java b/src/main/java/org/pkwmtt/mail/config/MailConfig.java index 595d744..15a4680 100644 --- a/src/main/java/org/pkwmtt/mail/config/MailConfig.java +++ b/src/main/java/org/pkwmtt/mail/config/MailConfig.java @@ -1,7 +1,6 @@ package org.pkwmtt.mail.config; import jakarta.annotation.PostConstruct; -import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -9,40 +8,34 @@ import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; +import java.util.Objects; import java.util.Properties; @Configuration @RequiredArgsConstructor public class MailConfig { - @Getter - private static boolean enabled = true; - private final Environment environment; private String username; private String password; + private String host; + private int port; @PostConstruct private void assignAndValidateProperties () { username = environment.getProperty("spring.mail.username"); password = environment.getProperty("spring.mail.password"); - - if (username == null || password == null || username.isEmpty() || password.isEmpty()) { - enabled = false; - } + host = environment.getProperty("spring.mail.host"); + port = Integer.parseInt(Objects.requireNonNull(environment.getProperty("spring.mail.port"))); } @Bean public JavaMailSender javaMailSender () { JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); - if (!enabled) { - return mailSender; - } - - mailSender.setHost("smtp.gmail.com"); - mailSender.setPort(587); + mailSender.setHost(host); + mailSender.setPort(port); mailSender.setUsername(username); mailSender.setPassword(password); diff --git a/src/main/java/org/pkwmtt/otp/OTPController.java b/src/main/java/org/pkwmtt/otp/OTPController.java new file mode 100644 index 0000000..284fa21 --- /dev/null +++ b/src/main/java/org/pkwmtt/otp/OTPController.java @@ -0,0 +1,32 @@ +package org.pkwmtt.otp; + + +import com.mysql.cj.exceptions.WrongArgumentException; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.exceptions.*; +import org.pkwmtt.otp.dto.OTPRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("${apiPrefix}/representatives") +@RequiredArgsConstructor +public class OTPController { + private final OTPService service; + + @GetMapping("/authenticate") + public ResponseEntity authenticate (@RequestParam(name = "c") String code) + throws OTPCodeNotFoundException, WrongOTPFormatException, UserNotFoundException { + return ResponseEntity.ok(service.generateTokenForRepresentative(code)); + } + + @PostMapping("/codes/generate") + public ResponseEntity generateCodes (@RequestBody List request) + throws MailCouldNotBeSendException, WrongArgumentException, SpecifiedGeneralGroupDoesntExistsException { + service.sendOTPCodesForManyGroups(request); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/org/pkwmtt/otp/OTPExceptionHandler.java b/src/main/java/org/pkwmtt/otp/OTPExceptionHandler.java new file mode 100644 index 0000000..0dc2849 --- /dev/null +++ b/src/main/java/org/pkwmtt/otp/OTPExceptionHandler.java @@ -0,0 +1,23 @@ +package org.pkwmtt.otp; + + +import com.mysql.cj.exceptions.WrongArgumentException; +import org.pkwmtt.exceptions.*; +import org.pkwmtt.exceptions.dto.ErrorResponseDTO; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = {OTPController.class}) +public class OTPExceptionHandler { + @ExceptionHandler({OTPCodeNotFoundException.class, WrongOTPFormatException.class, UserNotFoundException.class, WrongArgumentException.class, SpecifiedGeneralGroupDoesntExistsException.class}) + public ResponseEntity handleBadRequests (Exception e) { + return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler({MailCouldNotBeSendException.class}) + public ResponseEntity handleServerErrors (Exception e) { + return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/org/pkwmtt/otp/OTPService.java b/src/main/java/org/pkwmtt/otp/OTPService.java new file mode 100644 index 0000000..24d55c0 --- /dev/null +++ b/src/main/java/org/pkwmtt/otp/OTPService.java @@ -0,0 +1,164 @@ +package org.pkwmtt.otp; + +import com.mysql.cj.exceptions.WrongArgumentException; +import jakarta.mail.MessagingException; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.examCalendar.entity.GeneralGroup; +import org.pkwmtt.examCalendar.entity.OTPCode; +import org.pkwmtt.examCalendar.entity.User; +import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.examCalendar.repository.GeneralGroupRepository; +import org.pkwmtt.examCalendar.repository.UserRepository; +import org.pkwmtt.exceptions.*; +import org.pkwmtt.mail.EmailService; +import org.pkwmtt.mail.dto.MailDTO; +import org.pkwmtt.otp.dto.OTPRequest; +import org.pkwmtt.otp.repository.OTPCodeRepository; +import org.pkwmtt.security.token.JwtServiceImpl; +import org.pkwmtt.security.token.dto.UserDTO; +import org.pkwmtt.timetable.TimetableService; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; +import java.util.Random; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class OTPService { + private final OTPCodeRepository otpRepository; + private final UserRepository userRepository; + private final GeneralGroupRepository generalGroupRepository; + private final EmailService emailService; + private final JwtServiceImpl jwtService; + private final TimetableService timetableService; + + public String generateTokenForRepresentative (String code) + throws OTPCodeNotFoundException, WrongOTPFormatException, UserNotFoundException { + var generalGroup = this.getGeneralGroupAssignedToCode(code); + var user = userRepository + .findByGeneralGroup(generalGroup) + .orElseThrow(() -> new UserNotFoundException("No user is assigned to this code.")); + + var userEmail = user.getEmail(); + String token = jwtService.generateToken(new UserDTO() + .setEmail(userEmail) + .setRole(Role.REPRESENTATIVE) + .setGroup(generalGroup.getName())); + otpRepository.deleteByCode(code); + return token; + } + + public void sendOTPCodesForManyGroups (List requests) + throws MailCouldNotBeSendException, WrongArgumentException, SpecifiedSubGroupDoesntExistsException { + requests.forEach(request -> { + var code = generateNewCode(); + var mail = createMail(request, code); + var groupName = request.getGeneralGroupName(); + var groupNameLength = groupName.length(); + + if (groupNameLength > 3 && Character.isDigit(groupName.charAt(groupNameLength - 1))) { + throw new WrongArgumentException( + "Wrong general group provided. Make sure you are not providing subgroup. (f.e 12K1 -> wrong, 12K -> good)"); + } + + if (!generalGroupExists(groupName)) { + throw new SpecifiedGeneralGroupDoesntExistsException(); + } + + var generalGroup = generalGroupRepository.findByName(groupName); + + if (generalGroup.isPresent()) { + if (otpRepository.existsOTPCodeByGeneralGroup(generalGroup.get())) { + throw new RuntimeException(""); + } + } else { + generalGroup = Optional.of(generalGroupRepository.save(new GeneralGroup(null, groupName))); + } + + try { + emailService.send(mail); + } catch (MessagingException e) { + throw new MailCouldNotBeSendException("Couldn't send mail for group: " + groupName); + } + + var user = User + .builder() + .email(request.getEmail()) + .generalGroup(generalGroup.get()) + .role(Role.REPRESENTATIVE) + .isActive(true) + .build(); + + userRepository.save(user); + + otpRepository.save(new OTPCode(code, generalGroup.get())); + }); + } + + private GeneralGroup getGeneralGroupAssignedToCode (String code) throws OTPCodeNotFoundException, WrongOTPFormatException { + this.validateCode(code); + + Optional result = otpRepository.findByCode(code); + + if (result.isEmpty()) { + throw new OTPCodeNotFoundException(); + } + + return result.get().getGeneralGroup(); + } + + private void validateCode (String code) throws WrongOTPFormatException { + if (code.length() != 6) { + throw new WrongOTPFormatException("Code should be 6 characters long."); + } + + String regex = "^[A-Z0-9]{6}$"; + Pattern pattern = Pattern.compile(regex); + Matcher matcher = pattern.matcher(code); + + if (!matcher.find()) { + throw new WrongOTPFormatException("Wrong format of provided code."); + } + } + + + private MailDTO createMail (OTPRequest request, String code) { + return new MailDTO() + .setTitle("Kod Starosty " + request.getGeneralGroupName()) + .setRecipient(request.getEmail()) + .setDescription(request.getMailMessage(code)); + } + + private String generateNewCode () { + String availableCharacters = "ABCDEFGHIJKLMNOPQRSTUWXYZ0123456789"; + StringBuilder code = new StringBuilder(); + Random random = new Random(); + + do { + code.setLength(0); + for (int i = 0; i < 6; i++) { + code.append(availableCharacters.charAt(random.nextInt(availableCharacters.length()))); + } + } while (otpRepository.findByCode(code.toString()).isPresent()); + + return code.toString(); + } + + private boolean generalGroupExists (String name) { + Set list = timetableService.getGeneralGroupList().stream().map(item -> { + var lastIndex = item.length() - 1; + if (Character.isDigit(item.charAt(lastIndex))) { + return item.substring(0, lastIndex); + } + return item; + }).collect(Collectors.toSet()); + + return list.contains(name); + } + +} diff --git a/src/main/java/org/pkwmtt/otp/dto/OTPRequest.java b/src/main/java/org/pkwmtt/otp/dto/OTPRequest.java new file mode 100644 index 0000000..af310f5 --- /dev/null +++ b/src/main/java/org/pkwmtt/otp/dto/OTPRequest.java @@ -0,0 +1,24 @@ +package org.pkwmtt.otp.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class OTPRequest { + private String email; + private String generalGroupName; + + public String getMailMessage (String code) { + return String.format( + """ + Kod starosty %s
+ Poniżej znajduje się kod służący do ulepszenia wersji aplikacji do poziomu starosty.
+ Dzięki temu będziesz mógł dodawać oraz usuwać egzaminy dla swojego kierunku w kalendarzu aplikacji.
+ Wpisz kod w [Ustawienia > Wpisz kod], albo przekaż go osobie odpowiedzialnej za kalendarz egzaminów.
+ Twój kod: %s
+ Na wykorzystanie kodu masz 1 dzień.
+ """, generalGroupName, code + ); + } +} diff --git a/src/main/java/org/pkwmtt/otp/repository/OTPCodeRepository.java b/src/main/java/org/pkwmtt/otp/repository/OTPCodeRepository.java new file mode 100644 index 0000000..6fca6a6 --- /dev/null +++ b/src/main/java/org/pkwmtt/otp/repository/OTPCodeRepository.java @@ -0,0 +1,19 @@ +package org.pkwmtt.otp.repository; + +import jakarta.transaction.Transactional; +import org.pkwmtt.examCalendar.entity.GeneralGroup; +import org.pkwmtt.examCalendar.entity.OTPCode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OTPCodeRepository extends JpaRepository { + Optional findByCode (String code); + + @Transactional + void deleteByCode (String code); + + boolean existsOTPCodeByGeneralGroup (GeneralGroup generalGroup); + + boolean existsOTPCodeByCode (String code); +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/repository/ExamRepository.java b/src/main/java/org/pkwmtt/repository/ExamRepository.java deleted file mode 100644 index 2faafaa..0000000 --- a/src/main/java/org/pkwmtt/repository/ExamRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.pkwmtt.repository; - -import org.pkwmtt.entity.Exam; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExamRepository extends JpaRepository { -} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/repository/ExamTypeRepository.java b/src/main/java/org/pkwmtt/repository/ExamTypeRepository.java deleted file mode 100644 index 1b7d38c..0000000 --- a/src/main/java/org/pkwmtt/repository/ExamTypeRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.pkwmtt.repository; - -import org.pkwmtt.entity.ExamType; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExamTypeRepository extends JpaRepository { -} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/repository/GeneralGroupRepository.java b/src/main/java/org/pkwmtt/repository/GeneralGroupRepository.java deleted file mode 100644 index a4c1c55..0000000 --- a/src/main/java/org/pkwmtt/repository/GeneralGroupRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.pkwmtt.repository; - -import org.pkwmtt.entity.GeneralGroup; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface GeneralGroupRepository extends JpaRepository { -} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/repository/GroupRepository.java b/src/main/java/org/pkwmtt/repository/GroupRepository.java deleted file mode 100644 index b2396a9..0000000 --- a/src/main/java/org/pkwmtt/repository/GroupRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.pkwmtt.repository; - -import org.pkwmtt.entity.StudentGroup; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface GroupRepository extends JpaRepository { -} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/repository/OTPCodeRepository.java b/src/main/java/org/pkwmtt/repository/OTPCodeRepository.java deleted file mode 100644 index 4f79485..0000000 --- a/src/main/java/org/pkwmtt/repository/OTPCodeRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.pkwmtt.repository; - -import org.pkwmtt.entity.OTPCode; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface OTPCodeRepository extends JpaRepository { -} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/repository/UserRepository.java b/src/main/java/org/pkwmtt/repository/UserRepository.java deleted file mode 100644 index 71ccd75..0000000 --- a/src/main/java/org/pkwmtt/repository/UserRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.pkwmtt.repository; - -import org.pkwmtt.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface UserRepository extends JpaRepository { -} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/security/admin/AdminController.java b/src/main/java/org/pkwmtt/security/admin/AdminController.java new file mode 100644 index 0000000..b0bd2af --- /dev/null +++ b/src/main/java/org/pkwmtt/security/admin/AdminController.java @@ -0,0 +1,32 @@ +package org.pkwmtt.security.admin; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.security.apiKey.ApiKeyService; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/admin") +public class AdminController { + private final ApiKeyService service; + + @GetMapping("") + public String adminPanel () { + return "ADMIN"; + } + + @PostMapping("/api/keys/generate") + public String generateApiKey (@RequestParam(name = "d") String description, @RequestParam(name = "r") Role role) { + return service.generateApiKey(description, role); + } + + @GetMapping("/api/keys") + public Map getMapOfPublicApiKeys () { + return service.getMapOfPublicApiKeys(); + } + + +} diff --git a/src/main/java/org/pkwmtt/security/admin/AdminRequestInterceptor.java b/src/main/java/org/pkwmtt/security/admin/AdminRequestInterceptor.java new file mode 100644 index 0000000..5683b46 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/admin/AdminRequestInterceptor.java @@ -0,0 +1,40 @@ +package org.pkwmtt.security.admin; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.apache.logging.log4j.util.InternalException; +import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.exceptions.IncorrectApiKeyValue; +import org.pkwmtt.exceptions.MissingHeaderException; +import org.pkwmtt.security.apiKey.ApiKeyService; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@RequiredArgsConstructor +@Component +public class AdminRequestInterceptor implements HandlerInterceptor { + private final ApiKeyService apiKeyService; + + @Override + public boolean preHandle (@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) { + String headerName = "X-ADMIN-KEY"; + try { + String providedApiKey = request.getHeader(headerName); + + if (providedApiKey == null || providedApiKey.isBlank()) { + throw new MissingHeaderException(headerName); + } + + apiKeyService.validateApiKey(providedApiKey, Role.ADMIN); + } catch (IncorrectApiKeyValue | MissingHeaderException e) { + throw new IncorrectApiKeyValue(); + } catch (Exception e) { + throw new InternalException("Internal server error with validating API key."); + } + + return true; + } + +} diff --git a/src/main/java/org/pkwmtt/security/admin/entity/AdminKey.java b/src/main/java/org/pkwmtt/security/admin/entity/AdminKey.java new file mode 100644 index 0000000..3d6031b --- /dev/null +++ b/src/main/java/org/pkwmtt/security/admin/entity/AdminKey.java @@ -0,0 +1,19 @@ +package org.pkwmtt.security.admin.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.pkwmtt.security.apiKey.entity.BaseApiKeyEntity; + +@Entity +@Table(name = "admin_keys") +@NoArgsConstructor +@Getter +public class AdminKey extends BaseApiKeyEntity { + + public AdminKey (String value, String description) { + super(value, description); + } + +} diff --git a/src/main/java/org/pkwmtt/security/admin/repository/AdminKeyRepository.java b/src/main/java/org/pkwmtt/security/admin/repository/AdminKeyRepository.java new file mode 100644 index 0000000..a6d8744 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/admin/repository/AdminKeyRepository.java @@ -0,0 +1,8 @@ +package org.pkwmtt.security.admin.repository; + +import org.pkwmtt.security.admin.entity.AdminKey; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdminKeyRepository extends JpaRepository { + boolean existsApiKeyByValue (String value); +} diff --git a/src/main/java/org/pkwmtt/security/apiKey/ApiKeyService.java b/src/main/java/org/pkwmtt/security/apiKey/ApiKeyService.java new file mode 100644 index 0000000..53b79da --- /dev/null +++ b/src/main/java/org/pkwmtt/security/apiKey/ApiKeyService.java @@ -0,0 +1,68 @@ +package org.pkwmtt.security.apiKey; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.exceptions.IncorrectApiKeyValue; +import org.pkwmtt.security.admin.entity.AdminKey; +import org.pkwmtt.security.admin.repository.AdminKeyRepository; +import org.pkwmtt.security.apiKey.entity.ApiKey; +import org.pkwmtt.security.apiKey.repository.ApiKeyRepository; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ApiKeyService { + + private final ApiKeyRepository apiKeyRepository; + private final AdminKeyRepository adminKeyRepository; + + public String generateApiKey (String description, Role role) { + String value = UUID.randomUUID().toString(); + if (role == Role.REPRESENTATIVE) { + apiKeyRepository.save(new ApiKey(value, description)); + } else if (role == Role.ADMIN) { + adminKeyRepository.save(new AdminKey(value, description)); + } + return value; + } + + public void validateApiKey (String value, Role role) throws IncorrectApiKeyValue { + try { + UUID.fromString(value); + } catch (IllegalArgumentException e) { + throw new IncorrectApiKeyValue(); + } + + + if (existsInAdminKeyBase(value)) { // Admin can access all endpoint + return; + } + + if (role != Role.ADMIN && existsInPublicKeyBase(value)) { //Normal user access + return; + } + + throw new IncorrectApiKeyValue(); + } + + public boolean existsInPublicKeyBase (String value) { + return apiKeyRepository.existsApiKeyByValue(value); + } + + public boolean existsInAdminKeyBase (String value) { + return adminKeyRepository.existsApiKeyByValue(value); + } + + public Map getMapOfPublicApiKeys () { + Map objectMap = new HashMap<>(); + + apiKeyRepository.findAll().forEach(item -> objectMap.put(item.getValue(), item.getDescription())); + + return objectMap; + } + +} diff --git a/src/main/java/org/pkwmtt/security/apiKey/entity/ApiKey.java b/src/main/java/org/pkwmtt/security/apiKey/entity/ApiKey.java new file mode 100644 index 0000000..ec5f2ae --- /dev/null +++ b/src/main/java/org/pkwmtt/security/apiKey/entity/ApiKey.java @@ -0,0 +1,17 @@ +package org.pkwmtt.security.apiKey.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "api_keys") +@Getter +@NoArgsConstructor +public class ApiKey extends BaseApiKeyEntity { + + public ApiKey (String value, String description) { + super(value, description); + } + +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/security/apiKey/entity/BaseApiKeyEntity.java b/src/main/java/org/pkwmtt/security/apiKey/entity/BaseApiKeyEntity.java new file mode 100644 index 0000000..58343ee --- /dev/null +++ b/src/main/java/org/pkwmtt/security/apiKey/entity/BaseApiKeyEntity.java @@ -0,0 +1,25 @@ +package org.pkwmtt.security.apiKey.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@MappedSuperclass +@NoArgsConstructor +@Getter +public abstract class BaseApiKeyEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Integer key_id; + + @Column(nullable = false) + protected String value; + + @Column(nullable = false) + protected String description; + + public BaseApiKeyEntity (String value, String description) { + this.value = value; + this.description = description; + } +} diff --git a/src/main/java/org/pkwmtt/security/apiKey/repository/ApiKeyRepository.java b/src/main/java/org/pkwmtt/security/apiKey/repository/ApiKeyRepository.java new file mode 100644 index 0000000..c33a6ee --- /dev/null +++ b/src/main/java/org/pkwmtt/security/apiKey/repository/ApiKeyRepository.java @@ -0,0 +1,8 @@ +package org.pkwmtt.security.apiKey.repository; + +import org.pkwmtt.security.apiKey.entity.ApiKey; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApiKeyRepository extends JpaRepository { + boolean existsApiKeyByValue (String value); +} diff --git a/src/main/java/org/pkwmtt/security/auth/provider/OTPAuthenticationProvider.java b/src/main/java/org/pkwmtt/security/auth/provider/OTPAuthenticationProvider.java new file mode 100644 index 0000000..0d404a8 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/auth/provider/OTPAuthenticationProvider.java @@ -0,0 +1,57 @@ +package org.pkwmtt.security.auth.provider; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.examCalendar.entity.User; +import org.pkwmtt.examCalendar.repository.UserRepository; +import org.pkwmtt.security.token.dto.UserDTO; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Stream; +//TODO delete +@Component +@RequiredArgsConstructor +public class OTPAuthenticationProvider implements AuthenticationProvider { + private final UserRepository userRepository; + + @Override + public Authentication authenticate (Authentication authentication) throws AuthenticationException { + String email = authentication.getName(); + + // Fetch user from DB + User user = userRepository.findByEmail(email).orElseThrow(() -> new BadCredentialsException("User not found")); + + // Wrap role in a list to support multiple roles in the future + List authorities = Stream + .of(user.getRole()) + .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())) + .toList(); + + // Validate critical user fields + if (!isValidForAuthentication(user)) { + throw new BadCredentialsException("Invalid User Credentials. Please contact the administrator."); + } + + UserDTO userMapped = new UserDTO(user); + return new UsernamePasswordAuthenticationToken(userMapped, null, authorities); + } + + @Override + public boolean supports (Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } + + /** + * Validates user data before authentication. + * Returns true if user has email, role, group, and is active. + */ + private boolean isValidForAuthentication (User user) { + return user.getEmail() != null && user.getRole() != null && user.getGeneralGroup() != null && user.isActive(); + } +} diff --git a/src/main/java/org/pkwmtt/security/config/SpringSecurity.java b/src/main/java/org/pkwmtt/security/config/SpringSecurity.java index c4dc591..e6e94fc 100644 --- a/src/main/java/org/pkwmtt/security/config/SpringSecurity.java +++ b/src/main/java/org/pkwmtt/security/config/SpringSecurity.java @@ -11,22 +11,19 @@ import static org.springframework.security.config.Customizer.withDefaults; import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; -//@EnableWebSecurity +@EnableWebSecurity @Slf4j @Configuration public class SpringSecurity { - + @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { log.info("Configuring Security Filter Chain..."); http - .cors(withDefaults()) - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/**").permitAll() - .anyRequest().authenticated() - ) - .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)); + .cors(withDefaults()) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth.requestMatchers("/**").permitAll().anyRequest().authenticated()) + .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)); log.info("Configuring Success..."); return http.build(); } diff --git a/src/main/java/org/pkwmtt/security/cors/SecurityCorsConfig.java b/src/main/java/org/pkwmtt/security/cors/SecurityCorsConfig.java index 48aca29..5107d21 100644 --- a/src/main/java/org/pkwmtt/security/cors/SecurityCorsConfig.java +++ b/src/main/java/org/pkwmtt/security/cors/SecurityCorsConfig.java @@ -23,8 +23,7 @@ public class SecurityCorsConfig { @Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); -// TODO: change host - config.setAllowedOrigins(List.of("http://localhost:5173")); + config.setAllowedOrigins(List.of("https://pkwmapp.pl")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); //??? diff --git a/src/main/java/org/pkwmtt/security/token/JwtService.java b/src/main/java/org/pkwmtt/security/token/JwtService.java new file mode 100644 index 0000000..e62bc7b --- /dev/null +++ b/src/main/java/org/pkwmtt/security/token/JwtService.java @@ -0,0 +1,12 @@ +package org.pkwmtt.security.token; + +import org.pkwmtt.examCalendar.entity.User; +import org.pkwmtt.security.token.dto.UserDTO; + +import java.util.Optional; + +public interface JwtService { + String generateToken(UserDTO user); + Boolean validateToken(String token, User user); + String getUserEmailFromToken(String token); +} diff --git a/src/main/java/org/pkwmtt/security/token/JwtServiceImpl.java b/src/main/java/org/pkwmtt/security/token/JwtServiceImpl.java new file mode 100644 index 0000000..0e2858d --- /dev/null +++ b/src/main/java/org/pkwmtt/security/token/JwtServiceImpl.java @@ -0,0 +1,133 @@ +package org.pkwmtt.security.token; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.examCalendar.entity.User; +import org.pkwmtt.security.token.dto.UserDTO; +import org.pkwmtt.security.token.utils.JwtUtils; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; +import java.util.function.Function; + +@Service +@RequiredArgsConstructor +public class JwtServiceImpl implements JwtService { + private final JwtUtils jwtUtils; + + /** + * Generates a JWT token for a given user. + * The token contains user's email, group, and role as claims, + * and is signed with a secret key. + * + * @param user - required user data to include in token claims + * @return signed JWT token as a String + */ + @Override + public String generateToken(UserDTO user) { + return Jwts.builder() + .subject(user.getEmail()) + .claim("group", user.getGroup()) + .claim("role", user.getRole()) + .issuedAt(new Date()) + .expiration((new Date(System.currentTimeMillis() + jwtUtils.getExpirationMs()))) + .signWith(decodeSecretKey()) + .compact(); + } + + /** + * Decode a secret key for signing JWT. + * The key is decoded from Base64 stored in JwtUtils configuration. + * + * @return secret key for JWT signing + */ + SecretKey decodeSecretKey(){ + byte[] decodedKey = Base64.getDecoder().decode(jwtUtils.getSecret()); + return Keys.hmacShaKeyFor(decodedKey); + } + + /** + * Validate a JWT token. + * Attempts to parse the token; if parsing fails, the token is considered invalid. + * + * @param token JWT token string to validate + * @return true if the token is valid, false otherwise + */ + @Override + public Boolean validateToken(String token, User user) { + try { + final String userEmail = getUserEmailFromToken(token); + return userEmail != null + && userEmail.equals(user.getEmail()) + && !isTokenExpired(token); + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** + * Extracts the user identifier (email) from a JWT token. + * + * @param token JWT token to extract user from + * @return user email from token + */ + @Override + public String getUserEmailFromToken(String token) { + return extractClaim(token, Claims::getSubject); + } + + /** + * Extracts the expiration date from a JWT token. + * + * @param token JWT token string + * @return expiration date of the token + */ + private Date getExpirationDateFromToken(String token) { + return extractClaim(token, Claims::getExpiration); + } + + /** + * Checks whether a JWT token has expired. + * + * @param token JWT token string + * @return true if the token is expired, false otherwise + */ + private boolean isTokenExpired(String token){ + return getExpirationDateFromToken(token).before(new Date()); + } + + /** + * Extracts a specific claim from a JWT token using a claim resolver function. + * + * @param type of the claim + * @param token JWT token string + * @param claimResolver function to extract the desired claim from Claims + * @return the extracted claim of type T + */ + T extractClaim(String token, Function claimResolver) { + final Claims claims = extractAllClaims(token); + return claimResolver.apply(claims); + } + + /** + * Parses the JWT token and returns all claims contained in its payload. + *

+ * The method verifies the token signature using the secret key. + * + * @param token JWT token string + * @return Claims object containing all claims from the token payload + * @throws JwtException if the token is invalid or the signature does not match + */ + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(decodeSecretKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/src/main/java/org/pkwmtt/security/token/dto/UserDTO.java b/src/main/java/org/pkwmtt/security/token/dto/UserDTO.java new file mode 100644 index 0000000..2c69368 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/token/dto/UserDTO.java @@ -0,0 +1,25 @@ +package org.pkwmtt.security.token.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; +import org.pkwmtt.examCalendar.entity.GeneralGroup; +import org.pkwmtt.examCalendar.entity.User; +import org.pkwmtt.examCalendar.enums.Role; + +import java.util.Optional; + +@Data +@NoArgsConstructor +@Accessors(chain = true) +public class UserDTO { + private String email; + private String group; + private Role role; + + public UserDTO (User user) { + this.email = user.getEmail(); + this.role = user.getRole(); + this.group = Optional.ofNullable(user.getGeneralGroup()).map(GeneralGroup::getName).orElse(null); + } +} diff --git a/src/main/java/org/pkwmtt/security/token/filter/JwtFilter.java b/src/main/java/org/pkwmtt/security/token/filter/JwtFilter.java new file mode 100644 index 0000000..f5d6749 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/token/filter/JwtFilter.java @@ -0,0 +1,81 @@ +package org.pkwmtt.security.token.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.pkwmtt.examCalendar.entity.User; +import org.pkwmtt.examCalendar.repository.UserRepository; +import org.pkwmtt.security.token.JwtService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +public class JwtFilter extends OncePerRequestFilter { + + @Autowired + JwtService jwtService; + + @Autowired + UserRepository userRepository; + + /** + * Filters incoming HTTP requests to validate JWT tokens. + * + *

This filter: + * - Extracts the JWT token from the Authorization header. + * - Validates the token using JwtService. + * - Loads the user from UserRepository. + * - Sets the Spring Security Authentication in the SecurityContext. + * + * @param request the HttpServletRequest + * @param response the HttpServletResponse + * @param filterChain the FilterChain + * @throws ServletException if a servlet error occurs + * @throws IOException if an I/O error occurs + */ + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + String token = null; + String email = null; + + if (authHeader != null && authHeader.startsWith("Bearer ")) { + token = authHeader.substring(7); + email = jwtService.getUserEmailFromToken(token); + } + + if (email != null && SecurityContextHolder.getContext().getAuthentication() == null) { + User user = userRepository.findByEmail(email).orElseThrow(); + + if (jwtService.validateToken(token, user)) { + List authorities = List.of( + new SimpleGrantedAuthority("ROLE_" + user.getRole()) + ); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + user.getEmail(), + null, + authorities + ); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/org/pkwmtt/security/token/utils/JwtUtils.java b/src/main/java/org/pkwmtt/security/token/utils/JwtUtils.java new file mode 100644 index 0000000..21ee1bb --- /dev/null +++ b/src/main/java/org/pkwmtt/security/token/utils/JwtUtils.java @@ -0,0 +1,20 @@ +package org.pkwmtt.security.token.utils; + +import lombok.Getter; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Getter +@Component +public class JwtUtils { + // Secret key used for signing JWTs. If the environment variable JWT_SECRET_KEY + // is not set, a default value "TEST_SECRET" is used. This allows the application + // to start without a real secret, e.g., for local development or tests. + private final String secret; + private final long expirationMs = 1000L * 60 * 60 * 24 * 30 * 6; + + public JwtUtils(Environment environment) { + // Get the secret key from environment variables, or fallback to "TEST_SECRET" + this.secret = environment.getProperty("JWT_SECRET_KEY", "TEST_SECRET"); + } +} diff --git a/src/main/java/org/pkwmtt/status/DatabaseStatusChecker.java b/src/main/java/org/pkwmtt/status/DatabaseStatusChecker.java deleted file mode 100644 index 6af41a8..0000000 --- a/src/main/java/org/pkwmtt/status/DatabaseStatusChecker.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.pkwmtt.status; - - -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import javax.sql.DataSource; -import java.sql.SQLException; - -@Slf4j -@Service -public class DatabaseStatusChecker { - @Getter - private static boolean enabled = false; - - @Autowired - DatabaseStatusChecker (DataSource dataSource) { - try { - enabled = dataSource.getConnection().isValid(2); - } catch (SQLException e) { - log.error("Couldn't check database connection. Service will be unavailable"); - } - } -} diff --git a/src/main/java/org/pkwmtt/status/SystemStatusCheckerService.java b/src/main/java/org/pkwmtt/status/SystemStatusCheckerService.java deleted file mode 100644 index e7a4731..0000000 --- a/src/main/java/org/pkwmtt/status/SystemStatusCheckerService.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.pkwmtt.status; - -import jakarta.annotation.PostConstruct; -import org.pkwmtt.mail.config.MailConfig; -import org.pkwmtt.timetable.TimetableCacheService; -import org.pkwmtt.timetable.TimetableService; -import org.springframework.stereotype.Service; - - -@Service -public class SystemStatusCheckerService { - - private String mailingStatus; - private String databaseStatus; - private String cacheStatus; - private String timetableStatus; - - SystemStatusCheckerService () { - checkStatuses(); - } - - @PostConstruct - private void checkStatuses () { - mailingStatus = assignStatus(MailConfig.isEnabled()); - databaseStatus = assignStatus(DatabaseStatusChecker.isEnabled()); - timetableStatus = assignStatus(TimetableService.isEnabled()); - cacheStatus = assignStatus(TimetableCacheService.isCacheAvailable()); - } - - public String getStatus () { - return String.format( - """ - Server: ✅; - Services: - Mail: %s - Database: %s, - Timetable: %s, - Cache: %s - """, mailingStatus, databaseStatus, timetableStatus, cacheStatus - ); - } - - - private String assignStatus (boolean condition) { - return condition ? "✅" : "❌"; - } -} diff --git a/src/main/java/org/pkwmtt/status/SystemStatusController.java b/src/main/java/org/pkwmtt/status/SystemStatusController.java deleted file mode 100644 index dd055c1..0000000 --- a/src/main/java/org/pkwmtt/status/SystemStatusController.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.pkwmtt.status; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/pkwmtt/system/status") -@RequiredArgsConstructor -public class SystemStatusController { - private final SystemStatusCheckerService service; - - @GetMapping - public ResponseEntity getSystemStatus () { - return ResponseEntity.ok(service.getStatus()); - } - -} diff --git a/src/main/java/org/pkwmtt/timetable/TimetableCacheService.java b/src/main/java/org/pkwmtt/timetable/TimetableCacheService.java index 6bd4861..c36307d 100644 --- a/src/main/java/org/pkwmtt/timetable/TimetableCacheService.java +++ b/src/main/java/org/pkwmtt/timetable/TimetableCacheService.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.Getter; import org.jsoup.Jsoup; import org.pkwmtt.exceptions.SpecifiedGeneralGroupDoesntExistsException; import org.pkwmtt.exceptions.WebPageContentNotAvailableException; @@ -18,17 +17,12 @@ import java.util.List; import java.util.Map; -import static java.util.Objects.isNull; - @Service public class TimetableCacheService { private final TimetableParserService parser; private final ObjectMapper mapper; private final Cache cache; - @Getter - private static boolean cacheAvailable = true; - @Value("${main.url:https://podzial.mech.pk.edu.pl/stacjonarne/html/}") private String mainUrl; @@ -36,23 +30,6 @@ public TimetableCacheService (TimetableParserService parser, ObjectMapper mapper this.parser = parser; this.mapper = mapper; cache = cacheManager.getCache("timetables"); - - if (isNull(cache)) { - cacheAvailable = false; - } - } - - /** - * @return connection status - */ - public static boolean isConnectionAvailable () { - try { - fetchData("https://podzial.mech.pk.edu.pl/stacjonarne/html/"); - return true; - } catch (Exception e) { - System.out.println(e.getMessage()); - return false; - } } /** @@ -64,13 +41,13 @@ public static boolean isConnectionAvailable () { */ public TimetableDTO getGeneralGroupSchedule (String generalGroupName) throws WebPageContentNotAvailableException, SpecifiedGeneralGroupDoesntExistsException { - var generalGroupList = getGeneralGroupsMap(); + var generalGroupMap = getGeneralGroupsMap(); - if (!generalGroupList.containsKey(generalGroupName)) { + if (!generalGroupMap.containsKey(generalGroupName)) { throw new SpecifiedGeneralGroupDoesntExistsException(generalGroupName); } - String groupUrl = generalGroupList.get(generalGroupName); + String groupUrl = generalGroupMap.get(generalGroupName); String url = mainUrl + groupUrl; String cacheKey = "timetable_" + generalGroupName; var html = fetchData(url); @@ -96,10 +73,7 @@ public TimetableDTO getGeneralGroupSchedule (String generalGroupName) public Map getGeneralGroupsMap () throws WebPageContentNotAvailableException { var url = mainUrl + "lista.html"; var html = fetchData(url); - String json = cache.get( - "generalGroupMap", - () -> mapper.writeValueAsString(parser.parseGeneralGroups(html)) - ); + String json = cache.get("generalGroupMap", () -> mapper.writeValueAsString(parser.parseGeneralGroups(html))); return getMappedValue( json, "generalGroupList", cache, new TypeReference<>() { @@ -115,10 +89,7 @@ public Map getGeneralGroupsMap () throws WebPageContentNotAvaila */ public List getListOfHours () throws WebPageContentNotAvailableException { String url = mainUrl + "plany/o25.html"; - String json = cache.get( - "hourList", - () -> mapper.writeValueAsString(parser.parseHours(fetchData(url))) - ); + String json = cache.get("hourList", () -> mapper.writeValueAsString(parser.parseHours(fetchData(url)))); List result = getMappedValue( json, "hourList", cache, new TypeReference<>() { diff --git a/src/main/java/org/pkwmtt/timetable/TimetableController.java b/src/main/java/org/pkwmtt/timetable/TimetableController.java index 7110810..f7490d6 100644 --- a/src/main/java/org/pkwmtt/timetable/TimetableController.java +++ b/src/main/java/org/pkwmtt/timetable/TimetableController.java @@ -14,12 +14,12 @@ import static java.util.Objects.isNull; @RestController -@RequestMapping("/pkmwtt/api/v1/timetables") +@RequestMapping("${apiPrefix}/timetables") @RequiredArgsConstructor public class TimetableController { private final TimetableService service; private final TimetableCacheService cachedService; - + /** * Provide schedule of specified group and filters if all provided * @@ -29,20 +29,15 @@ public class TimetableController { * @throws WebPageContentNotAvailableException . */ @GetMapping("/{generalGroupName}") - public ResponseEntity getGeneralGroupSchedule ( - @PathVariable String generalGroupName, - @RequestParam(required = false, name = "sub") List subgroups) - throws WebPageContentNotAvailableException, SpecifiedGeneralGroupDoesntExistsException, - SpecifiedSubGroupDoesntExistsException, JsonProcessingException { - + public ResponseEntity getGeneralGroupSchedule (@PathVariable String generalGroupName, @RequestParam(required = false, name = "sub") List subgroups) + throws WebPageContentNotAvailableException, SpecifiedGeneralGroupDoesntExistsException, SpecifiedSubGroupDoesntExistsException, JsonProcessingException { + if (isNull(subgroups) || subgroups.isEmpty()) { return ResponseEntity.ok(cachedService.getGeneralGroupSchedule(generalGroupName)); } - return ResponseEntity.ok(service.getFilteredGeneralGroupSchedule( - generalGroupName, subgroups - )); + return ResponseEntity.ok(service.getFilteredGeneralGroupSchedule(generalGroupName, subgroups)); } - + /** * Provides list of schedule hours * @@ -50,22 +45,20 @@ public ResponseEntity getGeneralGroupSchedule ( * @throws WebPageContentNotAvailableException . */ @GetMapping("/hours") - public ResponseEntity> getListOfHours () - throws WebPageContentNotAvailableException { + public ResponseEntity> getListOfHours () throws WebPageContentNotAvailableException { return ResponseEntity.ok(cachedService.getListOfHours()); } - + /** * Provides list of general groups * * @return list of general groups */ @GetMapping("/groups/general") - public ResponseEntity> getListOfGeneralGroups () - throws WebPageContentNotAvailableException { + public ResponseEntity> getListOfGeneralGroups () throws WebPageContentNotAvailableException { return ResponseEntity.ok(service.getGeneralGroupList()); } - + /** * Provides list of available subgroups for specified general group * @@ -75,10 +68,13 @@ public ResponseEntity> getListOfGeneralGroups () */ @GetMapping("/groups/{generalGroupName}") public ResponseEntity> getListOfAvailableGroups (@PathVariable String generalGroupName) - throws JsonProcessingException, SpecifiedGeneralGroupDoesntExistsException, - WebPageContentNotAvailableException { + throws JsonProcessingException, SpecifiedGeneralGroupDoesntExistsException, WebPageContentNotAvailableException { return ResponseEntity.ok(service.getAvailableSubGroups(generalGroupName)); } - - + + @GetMapping("/{generalGroupName}/list") + public ResponseEntity> getListOfSubjects (@PathVariable String generalGroupName) { + return ResponseEntity.ok(service.getListOfSubjects(generalGroupName)); + } + } diff --git a/src/main/java/org/pkwmtt/timetable/TimetableService.java b/src/main/java/org/pkwmtt/timetable/TimetableService.java index 849cd66..55d0ce5 100644 --- a/src/main/java/org/pkwmtt/timetable/TimetableService.java +++ b/src/main/java/org/pkwmtt/timetable/TimetableService.java @@ -2,12 +2,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.pkwmtt.exceptions.SpecifiedGeneralGroupDoesntExistsException; import org.pkwmtt.exceptions.SpecifiedSubGroupDoesntExistsException; import org.pkwmtt.exceptions.WebPageContentNotAvailableException; import org.pkwmtt.timetable.dto.DayOfWeekDTO; +import org.pkwmtt.timetable.dto.SubjectDTO; import org.pkwmtt.timetable.dto.TimetableDTO; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -24,9 +24,6 @@ public class TimetableService { private final TimetableCacheService cachedService; - @Getter - private static final boolean enabled = TimetableCacheService.isConnectionAvailable(); - @Autowired TimetableService (TimetableCacheService cachedService) { this.cachedService = cachedService; @@ -40,8 +37,7 @@ public class TimetableService { * @throws JsonProcessingException if timetable conversion to JSON fails */ public List getAvailableSubGroups (String generalGroupName) - throws JsonProcessingException, SpecifiedGeneralGroupDoesntExistsException, - WebPageContentNotAvailableException { + throws JsonProcessingException, SpecifiedGeneralGroupDoesntExistsException, WebPageContentNotAvailableException { generalGroupName = generalGroupName.toUpperCase(); TimetableDTO timetable = cachedService.getGeneralGroupSchedule(generalGroupName); @@ -80,8 +76,7 @@ public List getAvailableSubGroups (String generalGroupName) * @throws WebPageContentNotAvailableException if source data can't be retrieved */ public TimetableDTO getFilteredGeneralGroupSchedule (String generalGroupName, List sub) - throws WebPageContentNotAvailableException, SpecifiedGeneralGroupDoesntExistsException, - JsonProcessingException { + throws WebPageContentNotAvailableException, SpecifiedGeneralGroupDoesntExistsException, JsonProcessingException { generalGroupName = generalGroupName.toUpperCase(); @@ -93,9 +88,7 @@ public TimetableDTO getFilteredGeneralGroupSchedule (String generalGroupName, Li } } - List schedule = cachedService - .getGeneralGroupSchedule(generalGroupName) - .getData(); + List schedule = cachedService.getGeneralGroupSchedule(generalGroupName).getData(); for (var day : schedule) { @@ -111,12 +104,24 @@ public TimetableDTO getFilteredGeneralGroupSchedule (String generalGroupName, Li * @return List of general group's names */ public List getGeneralGroupList () throws WebPageContentNotAvailableException { - return cachedService - .getGeneralGroupsMap() - .keySet() - .stream() - .sorted() - .collect(Collectors.toList()); + return cachedService.getGeneralGroupsMap().keySet().stream().sorted().collect(Collectors.toList()); + } + + public List getListOfSubjects (String generalGroupName) { + var subjectSet = new HashSet(); + var schedule = cachedService.getGeneralGroupSchedule(generalGroupName); + + schedule.getData().forEach(day -> { + day.getEven().forEach(subject -> addToSet(subjectSet, subject)); + day.getOdd().forEach(subject -> addToSet(subjectSet, subject)); + }); + + return subjectSet.stream().toList(); + } + + private void addToSet (Set subjectSet, SubjectDTO subject) { + subject.deleteTypeAndUnnecessaryCharactersFromName(); + subjectSet.add(subject.getName()); } } diff --git a/src/main/java/org/pkwmtt/timetable/dto/SubjectDTO.java b/src/main/java/org/pkwmtt/timetable/dto/SubjectDTO.java index 7b71ce3..141c80a 100644 --- a/src/main/java/org/pkwmtt/timetable/dto/SubjectDTO.java +++ b/src/main/java/org/pkwmtt/timetable/dto/SubjectDTO.java @@ -2,7 +2,7 @@ import lombok.*; import lombok.experimental.Accessors; -import org.pkwmtt.enums.SubjectType; +import org.pkwmtt.examCalendar.enums.SubjectType; import java.util.regex.Pattern; @@ -13,15 +13,13 @@ public class SubjectDTO { private String classroom; private int rowId; private SubjectType type; - - - public void deleteTypeAndUnnecessaryCharactersFromName() { - if (name.contains(" ")) + + + public void deleteTypeAndUnnecessaryCharactersFromName () { + if (name.contains(" ")) { this.name = name.substring(0, name.indexOf(' ')); - - name = name - .replaceAll("_", " ") - .replaceAll(Pattern.quote("("), "") - .replaceAll(Pattern.quote(")"), ""); + } + + name = name.replaceAll("_", " ").replaceAll(Pattern.quote("("), "").replaceAll(Pattern.quote(")"), ""); } } diff --git a/src/main/java/org/pkwmtt/timetable/parser/TimetableParserService.java b/src/main/java/org/pkwmtt/timetable/parser/TimetableParserService.java index af32752..46605d3 100644 --- a/src/main/java/org/pkwmtt/timetable/parser/TimetableParserService.java +++ b/src/main/java/org/pkwmtt/timetable/parser/TimetableParserService.java @@ -7,7 +7,7 @@ import org.jsoup.select.Elements; import org.pkwmtt.timetable.dto.DayOfWeekDTO; import org.pkwmtt.timetable.dto.SubjectDTO; -import org.pkwmtt.enums.SubjectType; +import org.pkwmtt.examCalendar.enums.SubjectType; import org.springframework.stereotype.Service; import java.util.ArrayList; diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index cd609c7..bff5ace 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -1,17 +1,31 @@ -spring.datasource.url=jdbc:mysql://localhost:3306/pktt?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC -spring.datasource.username=pkttuser -spring.datasource.password=pkttpassword - -server.port=8080 -server.address=0.0.0.0 - -spring.jpa.show-sql=true +### Properties for deployment +#Import .env variables +spring.config.import=optional:file:.env[.properties] +#Database +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} +spring.jpa.show-sql=false spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults=false spring.jpa.hibernate.ddl-auto=none spring.datasource.hikari.initialization-fail-timeout=0 - +#Server properties +server.port=8080 +server.address=0.0.0.0 +#Logging logging.file.name=logs/app.log logging.file.path=logs - +#Cache spring.cache.type=caffeine +#Test +logging.level.WireMock.my-mock=off +#Mail +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${EMAIL_USERNAME:} +spring.mail.password=${EMAIL_PASSWORD:} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +#Path +apiPrefix=/pkwmtt/api/v1 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a227ab6..876e8ad 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,29 +1,31 @@ +### Properties for deployment #Import .env variables spring.config.import=optional:file:.env[.properties] - -spring.datasource.url=jdbc:mysql://localhost:3306/pktt?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC -spring.datasource.username=pkttuser -spring.datasource.password=pkttpassword - -server.port=8080 -server.address=0.0.0.0 - +#Database +spring.datasource.url=${SPRING_DATASOURCE_URL} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} spring.jpa.show-sql=true spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults=false spring.jpa.hibernate.ddl-auto=none spring.datasource.hikari.initialization-fail-timeout=0 - +#Server properties +server.port=8080 +server.address=0.0.0.0 +#Logging logging.file.name=logs/app.log logging.file.path=logs - +#Cache spring.cache.type=caffeine - +#Test logging.level.WireMock.my-mock=off - +#Mail spring.mail.host=smtp.gmail.com spring.mail.port=587 spring.mail.username=${EMAIL_USERNAME:} spring.mail.password=${EMAIL_PASSWORD:} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true +#Path +apiPrefix=/pkwmtt/api/v1 \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index dcbbeca..b860289 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -6,12 +6,12 @@ + converterClass="org.pkwmtt.global.config.HighlightingCompositeLogConverter"/> - true + false %d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n diff --git a/src/test/java/org/pkwmtt/TESTS.md b/src/test/java/org/pkwmtt/TESTS.md deleted file mode 100644 index 388fc8e..0000000 --- a/src/test/java/org/pkwmtt/TESTS.md +++ /dev/null @@ -1,29 +0,0 @@ -# MockController Test Suite - -This repository contains a unit test for the `MockController` in a Spring Boot application using `@WebMvcTest`. It focuses on verifying the `/api/v1/hello` endpoint and includes Spring Security bypass configuration for testing purposes. - -## 📄 Overview - -- **Test Type**: Unit test (controller layer only) -- **Frameworks**: Spring Boot, JUnit 5, MockMvc -- **Security**: Bypassed using `@WithMockUser` and custom `SecurityConfig` -- **Target Endpoint**: `GET /api/v1/hello` - -## 🧪 How It Works - -The test class uses: -- `@WebMvcTest` to load only the web layer -- `MockMvc` to simulate HTTP requests -- `@WithMockUser` to mock an authenticated user -- `@Import(SecurityConfig.class)` to override security filters for testing - -## ✅ Example Test Case - -```java -@WithMockUser -@Test -public void getHello() throws Exception { - mockMvc.perform(get("/api/v1/hello")) - .andExpect(status().isOk()) - .andExpect(content().string("Hello")); -} diff --git a/src/test/java/org/pkwmtt/cache/CacheConfigTest.java b/src/test/java/org/pkwmtt/cache/CacheConfigTest.java index 6aa8251..53e9c30 100644 --- a/src/test/java/org/pkwmtt/cache/CacheConfigTest.java +++ b/src/test/java/org/pkwmtt/cache/CacheConfigTest.java @@ -5,7 +5,9 @@ import org.pkwmtt.ValuesForTest; import org.pkwmtt.timetable.TimetableCacheService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cache.CacheManager; +import org.springframework.test.context.ActiveProfiles; import test.TestConfig; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; @@ -14,71 +16,70 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +@SpringBootTest +@ActiveProfiles("test") class CacheConfigTest extends TestConfig { + @Autowired private TimetableCacheService service; - + @Autowired private CacheManager cacheManager; - + @BeforeEach - public void initWireMock() { - EXTERNAL_SERVICE_API_MOCK.stubFor(get(urlPathMatching("/plany/o25.html")) - .willReturn(aResponse() - .withStatus(200) - .withHeader("Content-Type", "text/*") - .withBody(ValuesForTest.timetableHTML))); - - EXTERNAL_SERVICE_API_MOCK.stubFor(get(urlPathMatching("/lista.html")) - .willReturn(aResponse() - .withStatus(200) - .withHeader("Content-Type", "text/*") - .withBody(ValuesForTest.listHTML))); + public void initWireMock () { + EXTERNAL_SERVICE_API_MOCK.stubFor(get(urlPathMatching("/plany/o25.html")).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/*") + .withBody(ValuesForTest.timetableHTML))); + + EXTERNAL_SERVICE_API_MOCK.stubFor(get(urlPathMatching("/lista.html")).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/*") + .withBody(ValuesForTest.listHTML))); } - + @Test - void testCacheKeyPresent_Schedule() { + void testCacheKeyPresent_Schedule () { //given - + //when service.getGeneralGroupSchedule("12K1"); var cache = cacheManager.getCache("timetables"); - + //then assertAll( - () -> { - assertThat(cache).isNotNull(); - assertThat(cache.get("generalGroupMap", String.class)) - .isEqualTo("{\"11K2\":\"plany/o8.html\",\"12K1\":\"plany/o25.html\",\"11A1\":\"plany/o1.html\",\"12K3\":\"plany/o27.html\",\"12K2\":\"plany/o26.html\"}"); - }, - () -> { - var wrapper = cache.get("timetable_12K1"); - assertThat(wrapper).isNotNull(); - assertThat(wrapper.get()).isInstanceOf(String.class); - } + () -> { + assertThat(cache).isNotNull(); + assertThat(cache.get("generalGroupMap", String.class)).isEqualTo( + "{\"11K2\":\"plany/o8.html\",\"12K1\":\"plany/o25.html\",\"11A1\":\"plany/o1.html\",\"12K3\":\"plany/o27.html\",\"12K2\":\"plany/o26.html\"}"); + }, () -> { + var wrapper = cache.get("timetable_12K1"); + assertThat(wrapper).isNotNull(); + assertThat(wrapper.get()).isInstanceOf(String.class); + } ); } - + @Test - void testCacheKeyPresent_HoursList(){ + void testCacheKeyPresent_HoursList () { //given - + //when service.getListOfHours(); var cache = cacheManager.getCache("timetables"); - + //then assertAll( - () -> { - assertThat(cache).isNotNull(); - assertThat(cache.get("hourList", String.class)) - .isEqualTo("[\"7:30- 8:15\",\"8:15- 9:00\",\"9:15-10:00\",\"10:00-10:45\",\"11:00-11:45\",\"11:45-12:30\",\"12:45-13:30\",\"13:30-14:15\",\"14:30-15:15\",\"15:15-16:00\",\"16:15-17:00\",\"17:00-17:45\",\"18:00-18:45\",\"18:45-19:30\",\"19:45-20:30\",\"20:30-21:15\"]"); - }, - () -> { - var wrapper = cache.get("hourList"); - assertThat(wrapper).isNotNull(); - assertThat(wrapper.get()).isInstanceOf(String.class); - } + () -> { + assertThat(cache).isNotNull(); + assertThat(cache.get("hourList", String.class)).isEqualTo( + "[\"7:30- 8:15\",\"8:15- 9:00\",\"9:15-10:00\",\"10:00-10:45\",\"11:00-11:45\",\"11:45-12:30\",\"12:45-13:30\",\"13:30-14:15\",\"14:30-15:15\",\"15:15-16:00\",\"16:15-17:00\",\"17:00-17:45\",\"18:00-18:45\",\"18:45-19:30\",\"19:45-20:30\",\"20:30-21:15\"]"); + }, () -> { + var wrapper = cache.get("hourList"); + assertThat(wrapper).isNotNull(); + assertThat(wrapper.get()).isInstanceOf(String.class); + } ); } } \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/cache/CacheInspector.java b/src/test/java/org/pkwmtt/cache/CacheInspector.java index 0b38463..fdf4749 100644 --- a/src/test/java/org/pkwmtt/cache/CacheInspector.java +++ b/src/test/java/org/pkwmtt/cache/CacheInspector.java @@ -6,6 +6,7 @@ import org.springframework.cache.CacheManager; import org.springframework.cache.caffeine.CaffeineCache; import org.springframework.stereotype.Component; +import org.springframework.test.context.ActiveProfiles; import java.util.Map; @@ -13,6 +14,7 @@ @Component @RequiredArgsConstructor +@ActiveProfiles("test") @SuppressWarnings("unused") public class CacheInspector { diff --git a/src/test/java/org/pkwmtt/examCalendar/ExamControllerTest.java b/src/test/java/org/pkwmtt/examCalendar/ExamControllerTest.java new file mode 100644 index 0000000..44c1549 --- /dev/null +++ b/src/test/java/org/pkwmtt/examCalendar/ExamControllerTest.java @@ -0,0 +1,853 @@ +package org.pkwmtt.examCalendar; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.pkwmtt.examCalendar.dto.ExamDto; +import org.pkwmtt.examCalendar.entity.Exam; +import org.pkwmtt.examCalendar.entity.ExamType; +import org.pkwmtt.examCalendar.entity.StudentGroup; +import org.pkwmtt.examCalendar.repository.ExamRepository; +import org.pkwmtt.examCalendar.repository.ExamTypeRepository; +import org.pkwmtt.examCalendar.repository.GroupRepository; +import org.pkwmtt.timetable.TimetableService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.EmbeddedDatabaseConnection; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * integration tests of ExamCalendar + */ +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2) +@ActiveProfiles("database") +class ExamControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ExamTypeRepository examTypeRepository; + + @Autowired + private ExamRepository examRepository; + + @Autowired + private ObjectMapper mapper; + + @Autowired + private GroupRepository groupRepository; + + @Mock + private TimetableService timetableService; + + @BeforeEach + void setupBeforeEach() { + examRepository.deleteAll(); + examTypeRepository.deleteAll(); + groupRepository.deleteAll(); + } + + // + + /** + * check if addExam endpoint create new exam with correct URI and correct data + */ + @Test + @Transactional + void addExamWithCorrectData() throws Exception { +// given + createExampleExamType("Project"); + ExamDto examDtoRequest = createExampleExamDto("Project"); + String json = mapper.writeValueAsString(examDtoRequest); + + when(timetableService.getGeneralGroupList()).thenReturn(List.of("12K1","12K2","12K3")); + when(timetableService.getAvailableSubGroups("12K2")).thenReturn(List.of("K04","L04","P04")); + + MvcResult result = mockMvc.perform(MockMvcRequestBuilders + .post("/pkwmtt/api/v1/exams") + .contentType("application/json") + .content(json) + ).andDo(print()) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", containsString("/pkwmtt/api/v1/exams/"))) + .andReturn(); + + String location = result.getResponse().getHeader("Location"); + @SuppressWarnings("DataFlowIssue") + int id = Integer.parseInt(location.substring(location.lastIndexOf("/") + 1)); + + Exam examResponse = examRepository.findById(id).orElseThrow(); + + Set responseSubgroups = examResponse.getGroups().stream() + .map(StudentGroup::getName) + .collect(Collectors.toSet()); + Set responseGeneralGroups = responseSubgroups.stream() + .filter(g -> g.matches("^\\d.*")) + .collect(Collectors.toSet()); + responseSubgroups.removeAll(responseGeneralGroups); + + assertEquals(responseGeneralGroups, Set.of("12K")); + assertEquals(responseSubgroups, examDtoRequest.getSubgroups()); + + assertEquals(examDtoRequest.getTitle(), examResponse.getTitle()); + assertEquals(examDtoRequest.getDescription(), examResponse.getDescription()); +// compare dates with minutes level precision + assertEquals( + examDtoRequest.getDate().truncatedTo(ChronoUnit.MINUTES), + examResponse.getExamDate().truncatedTo(ChronoUnit.MINUTES) + ); + + assertEquals(examDtoRequest.getExamType(), examResponse.getExamType().getName()); + } + + @Test + void addExamWithBlankExamTitle() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); + +// then + assertResponseMessage("title : must not be blank", result); + } + + @Test + void addExamWithBlankExamDescription() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); + + when(timetableService.getGeneralGroupList()).thenReturn(List.of("12K1","12K2","12K3")); + when(timetableService.getAvailableSubGroups("12K2")).thenReturn(List.of("K04","L04","P04")); +// when + MvcResult result = assertPostRequest(status().isCreated(), requestData); + + String location = result.getResponse().getHeader("Location"); + @SuppressWarnings("DataFlowIssue") + int id = Integer.parseInt(location.substring(location.lastIndexOf("/") + 1)); + + Exam examResponse = examRepository.findById(id).orElseThrow(); + assertNull(examResponse.getDescription()); + } + + @Test + void addExamWithBlankDate() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .description("first exam") + .examType("Project") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); + +// then + assertResponseMessage("date : must not be null", result); + } + + @Test + void addExamWithBlankExamGroups() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .build(); + +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); + +// then + assertResponseMessage("generalGroups : must not be empty", result); + } + + @Test + void addExamWithBlankGeneralGroups() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") +// null generalGroups + .subgroups(Set.of("L04")) + .build(); + +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); +// then + assertResponseMessage("generalGroups : must not be empty", result); + } + + @Test + @Transactional + void addExamWithBlankSubgroups() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .generalGroups(Set.of("12K2")) +// null subgroups + .build(); + + when(timetableService.getGeneralGroupList()).thenReturn(List.of("12K1","12K2","12K3")); + when(timetableService.getAvailableSubGroups("12K2")).thenReturn(List.of("K04","L04","P04")); + +// when + MvcResult result = assertPostRequest(status().isCreated(), requestData); +// then + String location = result.getResponse().getHeader("Location"); + @SuppressWarnings("DataFlowIssue") + int id = Integer.parseInt(location.substring(location.lastIndexOf("/") + 1)); + Exam examResponse = examRepository.findById(id).orElseThrow(); + + assertEquals("12K2", examResponse.getGroups().iterator().next().getName()); + } + + @Test + void addExamWithMultipleGeneralGroupsAndSubgroups() throws Exception { + // given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .generalGroups(Set.of("12K1", "12K2")) + .subgroups(Set.of("L04")) + .build(); + + when(timetableService.getGeneralGroupList()).thenReturn(List.of("12K1","12K2","12K3")); + when(timetableService.getAvailableSubGroups("12K2")).thenReturn(List.of("K04","L04","P04")); + +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); +// then + assertResponseMessage("Invalid group identifier: ambiguous general groups for subgroups", result); + } + + @Test + void addExamWithNullExamTypes() throws Exception { +// given + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType(null) // brak typu egzaminu + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) +// no examType + .build(); + +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); + +// then + assertResponseMessage("examType : must not be null", result); + } + + @Test + void addExamWithNotFutureDate() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().minusDays(1)) + .examType("Project") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); + +// then + assertResponseMessage("date : Date must be in the future", result); + } + + @Test + void addExamWithEmptyStringExamTitle() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); + +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); + +// then + assertResponseMessage("title : must not be blank", result); + } + + @Test + void addExamWithTooLongExamTitle() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("a".repeat(256)) // 256 znaków + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); + +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); + +// then + assertResponseMessage("title : max size of field is 255", result); + } + + @Test + void addExamWithTooLongDescription() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .description("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") // 256 znaków + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); + +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); + +// then + assertResponseMessage("description : max size of field is 255", result); + } + + @Test + void addExamWithNonExistingExamType() throws Exception { +// given + createExampleExamType("Project"); + ExamDto requestData = ExamDto.builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("NonExistingExamType") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); + + when(timetableService.getGeneralGroupList()).thenReturn(List.of("12K1","12K2","12K3")); + when(timetableService.getAvailableSubGroups("12K2")).thenReturn(List.of("K04","L04","P04")); + +// when + MvcResult result = assertPostRequest(status().isBadRequest(), requestData); + +// then + assertResponseMessage("Invalid exam type NonExistingExamType", result); + } + + + // + + // + @Test + @Transactional + void modifyExamWithCorrectData() throws Exception { +// given + ExamType examType = createExampleExamType("Exam"); + Exam exam = createExampleExam(examType); + int id = examRepository.save(exam).getExamId(); + ExamDto examDto = createExampleExamDto(examType.getName()); + + when(timetableService.getGeneralGroupList()).thenReturn(List.of("12K1","12K2","12K3")); + when(timetableService.getAvailableSubGroups("12K2")).thenReturn(List.of("K04","L04","P04")); + +// when + assertPutRequest(status().isNoContent(), examDto, id); + +// then + Exam responseExam = examRepository.findById(id).orElseThrow(); + + Set responseSubgroups = responseExam.getGroups().stream() + .map(StudentGroup::getName) + .collect(Collectors.toSet()); + Set responseGeneralGroups = responseSubgroups.stream() + .filter(g -> g.matches("^\\d.*")) + .collect(Collectors.toSet()); + responseSubgroups.removeAll(responseGeneralGroups); + + assertEquals("Math exam", responseExam.getTitle()); + assertEquals("first exam", responseExam.getDescription()); + assertEquals( + LocalDateTime.now().plusDays(1).truncatedTo(ChronoUnit.MINUTES), + responseExam.getExamDate().truncatedTo(ChronoUnit.MINUTES) + ); + assertEquals(Set.of("12K"), responseGeneralGroups); + assertEquals(Set.of("L04"), responseSubgroups); + } + + @Test + void modifyExamWithIncorrectExamId() throws Exception { +// given + ExamType examType = createExampleExamType("Exam"); + Exam exam = createExampleExam(examType); + int id = examRepository.save(exam).getExamId(); + ExamDto examDto = createExampleExamDto(examType.getName()); + + int invalidId = Integer.MAX_VALUE - 10; + assertNotEquals(invalidId, id); +// when + MvcResult result = assertPutRequest(status().isNotFound(), examDto, invalidId); + +// then + assertResponseMessage("No such element with id: " + (invalidId), result); + + } +// + + // + @Test + void deleteExamWithCorrectArguments() throws Exception { +// given + ExamType examType = createExampleExamType("Exam"); + Exam exam = createExampleExam(examType); + int id = examRepository.save(exam).getExamId(); + +// when + assertDeleteRequest(status().isNoContent(), id); + +// then + assertTrue(examRepository.findById(id).isEmpty()); + } + + @Test + void deleteNonExistingExam() throws Exception { +// given + ExamType examType = createExampleExamType("Exam"); + Exam exam = createExampleExam(examType); + int id = examRepository.save(exam).getExamId(); + int invalidId = Integer.MAX_VALUE - 10; + assertNotEquals(invalidId, id); + +// when + MvcResult result = assertDeleteRequest(status().isNotFound(), invalidId); + +// then + assertTrue(examRepository.findById(id).isPresent()); + assertResponseMessage("No such element with id: " + (invalidId), result); + } + + // + + // + + @Test + void getExamByIdWithCorrectId() throws Exception { +// given + ExamType examType = createExampleExamType("Exam"); + Exam exam = createExampleExam(examType); + int id = examRepository.save(exam).getExamId(); + +// when + MvcResult result = assertGetByIdRequest(status().isOk(), id); + JsonNode responseNode = mapper.readTree(result.getResponse().getContentAsString()); + +// then + assertEquals(exam.getTitle(), responseNode.get("title").asText()); + assertEquals(exam.getDescription(), responseNode.get("description").asText()); + assertEquals( + exam.getExamDate().truncatedTo(ChronoUnit.MINUTES), + LocalDateTime.parse(responseNode.get("examDate").textValue()).truncatedTo(ChronoUnit.MINUTES) + ); +// assertEquals(exam.getGroups(), responseNode.get("examGroups").asText()); + assertEquals(mapper.readTree(mapper.writeValueAsString(exam.getExamType())), responseNode.get("examType")); + } + + @Test + void getNonExistingExamById() throws Exception { +// given + ExamType examType = createExampleExamType("Exam"); + Exam exam = createExampleExam(examType); + int id = examRepository.save(exam).getExamId(); + int invalidId = Integer.MAX_VALUE - 10; + assertNotEquals(invalidId, id); + +// when + MvcResult result = assertGetByIdRequest(status().isNotFound(), invalidId); + +// then + assertResponseMessage("No such element with id: " + (invalidId), result); + } + +// + + @Test + void getExamsWithGeneralGroups() throws Exception { +// given + Exam exam1 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex1", Set.of("12K2"))); + Exam exam2 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex2", Set.of("12K2", "12K1"))); + Exam exam3 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex3", Set.of("12A2"))); + Exam exam4 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex4", Set.of("12K", "L04"))); + +// when + MvcResult result = assertGetByGroupsRequest(status().isOk(), Set.of("12K2")); + +// then + JsonNode responseArray = mapper.readTree(result.getResponse().getContentAsString()); + assertEquals(2, responseArray.size()); + assertTrue(responseArray.valueStream().anyMatch(e -> e.get("title").asText().equals(exam1.getTitle()))); + assertTrue(responseArray.valueStream().anyMatch(e -> e.get("title").asText().equals(exam2.getTitle()))); + assertTrue(responseArray.valueStream().noneMatch(e -> e.get("title").asText().equals(exam3.getTitle()))); + assertTrue(responseArray.valueStream().noneMatch(e -> e.get("title").asText().equals(exam4.getTitle()))); + } + + @Test + void getExamsWithSubgroups() throws Exception { +// given + Exam exam1 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex1", Set.of("12K2"))); + Exam exam2 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex2", Set.of("12K2", "11K2"))); + Exam exam3 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex3", Set.of("12A2"))); + Exam exam4 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex4", Set.of("12K", "L04"))); + Exam exam5 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex5", Set.of("11K", "L04"))); + +// when + MvcResult result = assertGetByGroupsRequest(status().isOk(), Set.of("11K2"), Set.of("L04","P04", "K04")); + +// then + JsonNode responseArray = mapper.readTree(result.getResponse().getContentAsString()); + assertEquals(2, responseArray.size()); + assertTrue(responseArray.valueStream().anyMatch(e -> e.get("title").asText().equals(exam2.getTitle()))); + assertTrue(responseArray.valueStream().anyMatch(e -> e.get("title").asText().equals(exam5.getTitle()))); + assertTrue(responseArray.valueStream().noneMatch(e -> e.get("title").asText().equals(exam1.getTitle()))); + assertTrue(responseArray.valueStream().noneMatch(e -> e.get("title").asText().equals(exam3.getTitle()))); + assertTrue(responseArray.valueStream().noneMatch(e -> e.get("title").asText().equals(exam4.getTitle()))); + } + + @Test + void getExamsWithSubgroupsUsingWholeYearIdentifier() throws Exception { +// given + Exam exam1 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex1", Set.of("12K2"))); + Exam exam2 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex2", Set.of("12K2", "11K2"))); + Exam exam3 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex3", Set.of("12A2"))); + Exam exam4 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex4", Set.of("12K", "L04"))); + Exam exam5 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex5", Set.of("11K", "L04"))); + Exam exam6 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex6", Set.of("12K", "L04", "P04"))); + +// when + MvcResult result = assertGetByGroupsRequest(status().isOk(), Set.of("12K"), Set.of("L04", "K04")); + +// then + JsonNode responseArray = mapper.readTree(result.getResponse().getContentAsString()); + assertEquals(2, responseArray.size()); + assertTrue(responseArray.valueStream().anyMatch(e -> e.get("title").asText().equals(exam4.getTitle()))); + assertTrue(responseArray.valueStream().anyMatch(e -> e.get("title").asText().equals(exam6.getTitle()))); + assertTrue(responseArray.valueStream().noneMatch(e -> e.get("title").asText().equals(exam1.getTitle()))); + assertTrue(responseArray.valueStream().noneMatch(e -> e.get("title").asText().equals(exam2.getTitle()))); + assertTrue(responseArray.valueStream().noneMatch(e -> e.get("title").asText().equals(exam3.getTitle()))); + assertTrue(responseArray.valueStream().noneMatch(e -> e.get("title").asText().equals(exam5.getTitle()))); + } + + @Test + void getExamsMultipleGeneralGroupsAndSubgroups() throws Exception { + // when + MvcResult result = assertGetByGroupsRequest(status().isBadRequest(), Set.of("11K2", "12A1"), Set.of("L04")); + // then + assertResponseMessage("Invalid group identifier: ambiguous superior group identifier for subgroups",result); + } + + @Test + void getExamsWithSwappedGroupNames() throws Exception { + // when + MvcResult result = assertGetByGroupsRequest(status().isBadRequest(), Set.of("K04"), Set.of("11K2", "12A1")); + // then + assertResponseMessage("Specified general group [K04] doesn't exists",result); + } + + @Test + void getExamsWithInvalidSubgroup() throws Exception { + // when + MvcResult result = assertGetByGroupsRequest(status().isBadRequest(), Set.of("12K1,", "12K2"), Set.of("11K2")); + // then + assertResponseMessage("Specified sub group [11K2] doesn't exists",result); + } + + // + + @Test + void getExamTypesWhenExamTypesExists() throws Exception { +// given + ExamType exam = createExampleExamType("Exam"); + ExamType project = createExampleExamType("Project"); + +// when + MvcResult result = assertGetExamTypesRequest(status().isOk()); + JsonNode responseArray = mapper.readTree(result.getResponse().getContentAsString()); + +// then + assertEquals(2, responseArray.size()); + assertTrue(responseArray.valueStream().anyMatch(e -> e.get("name").asText().equals(exam.getName()))); + assertTrue(responseArray.valueStream().anyMatch(e -> e.get("name").asText().equals(project.getName()))); + } + + @Test + void getExamTypesWhenExamTypesNotExists() throws Exception { +// given +// when + MvcResult result = mockMvc.perform(MockMvcRequestBuilders + .get("/pkwmtt/api/v1/exams/exam-types") + .contentType("application/json") + ).andDo(print()) + .andExpect(status().isOk()) + .andReturn(); + JsonNode responseArray = mapper.readTree(result.getResponse().getContentAsString()); + +// then + assertEquals(0, responseArray.size()); + } + + // + + // + + /** + * this method create examType object and add it to repository + * + * @param name of new examType + * @return created examType object + */ + private ExamType createExampleExamType(String name) { + ExamType examType = ExamType.builder().name(name).build(); + examTypeRepository.save(examType); + return examType; + } + + /** + * this method don't add created Exam to repository, because in that case id of created Exam would be unreachable + * + * @param type ExamType object which is required argument of Exam + * @return created Exam + */ + private Exam createExampleExam(ExamType type) { + List savedGroups = groupRepository.saveAll(Stream.of("12K2", "L04") + .map(g -> StudentGroup.builder().name(g).build()) + .collect(Collectors.toList())); + return Exam.builder() + .title("Exam") + .description("Exam description") + .examDate(LocalDateTime.now().plusDays(1)) + .groups(new HashSet<>(savedGroups)) + .examType(type) + .build(); + } + + private Exam createAndSaveExamWithTitleAndGroups(String title, Set groups) { + ExamType examType = examTypeRepository.findByName("Project") + .orElseGet(() -> createExampleExamType("Project")); + + Set groupsFromRepository = groupRepository.findAll().stream().map(StudentGroup::getName).collect(Collectors.toSet()); + groupRepository.saveAll(groups.stream().filter(g -> !groupsFromRepository.contains(g)) + .map(g -> StudentGroup.builder().name(g).build()) + .collect(Collectors.toList())); + + Set groupsToSave = groupRepository.findAll().stream().filter(g -> groups.contains(g.getName())).collect(Collectors.toSet()); + + return Exam.builder() + .title(title) + .description("Exam description") + .examDate(LocalDateTime.now().plusDays(1)) + .groups(groupsToSave) + .examType(examType) + .build(); + } + + /** + * @param examTypeName name of type of exam as String + * @return created ExamDto + */ + private ExamDto createExampleExamDto(String examTypeName) { + return ExamDto.builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType(examTypeName) + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); + } + + /** + * compare error message form response with expected value + * + * @param expectedMessage full message that is expected in response + * @param result response generated by mockMvc.perform() or one of assert[httpMethod]Request() + */ + private void assertResponseMessage(String expectedMessage, MvcResult result) throws Exception { + JsonNode jsonResponse = mapper.readTree(result.getResponse().getContentAsString()); + assertTrue(jsonResponse.has("message")); + assertEquals(expectedMessage, jsonResponse.get("message").asText()); + } + + /** + * method send POST request to ExamController with content as JSON attached to body and then check if response + * code is the same as expected + * + * @param expectedStatus status().[http response] (example: status().isCreated() ) + * @param content object that would be mapped to JSON by ObjectMapper and then attached to request + * it could be dto object or Map + * @return MvcResult object which could be used to capture response body + */ + private MvcResult assertPostRequest(ResultMatcher expectedStatus, Object content) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .post("/pkwmtt/api/v1/exams") + .contentType("application/json") + .content(mapper.writeValueAsString(content)) + ).andDo(print()) + .andExpect(expectedStatus) + .andReturn(); + } + + /** + * method send PUT request to ExamController with content as JSON attached to body and examId as pathID. + * Then check if response code is the same as expected + * + * @param expectedStatus status().[http response] (example: status().isNoContent() ) + * @param content object that would be mapped to JSON by ObjectMapper and then attached to request + * @param pathId id of resource that would be updated + * @return MvcResult object which could be used to capture response body + */ + private MvcResult assertPutRequest(ResultMatcher expectedStatus, Object content, int pathId) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .put("/pkwmtt/api/v1/exams/{id}", pathId) + .contentType("application/json") + .content(mapper.writeValueAsString(content)) + ).andDo(print()) + .andExpect(expectedStatus) + .andReturn(); + } + + /** + * method send DELETE request to ExamController with examId as pathID. + * Then check if response code is the same as expected + * + * @param expectedStatus status().[http response] (example: status().isNoContent() ) + * @param pathId id of resource that would be deleted + * @return MvcResult object which could be used to capture response body + */ + private MvcResult assertDeleteRequest(ResultMatcher expectedStatus, int pathId) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .delete("/pkwmtt/api/v1/exams/{id}", pathId) + .contentType("application/json") + ).andDo(print()) + .andExpect(expectedStatus) + .andReturn(); + } + + /** + * method send GET request to ExamController at /pkwmtt/api/v1/exams/{id} URI with examId as pathID. + * Then check if response code is the same as expected + * + * @param expectedStatus status().[http response] (example: status().isOk() ) + * @param pathId id of resource that would be returned + * @return MvcResult object which could be used to capture response body + */ + private MvcResult assertGetByIdRequest(ResultMatcher expectedStatus, int pathId) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .get("/pkwmtt/api/v1/exams/{id}", pathId) + .contentType("application/json") + ).andDo(print()) + .andExpect(expectedStatus) + .andReturn(); + } + + private MvcResult assertGetByGroupsRequest(ResultMatcher expectedStatus, Set generalGroups) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .get("/pkwmtt/api/v1/exams/by-groups") + .param("generalGroups", generalGroups.toArray(new String[0])) + .contentType("application/json") + ).andDo(print()) + .andExpect(expectedStatus) + .andReturn(); + } + + private MvcResult assertGetByGroupsRequest(ResultMatcher expectedStatus, Set generalGroups, Set subgroups) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .get("/pkwmtt/api/v1/exams/by-groups") + .param("generalGroups", generalGroups.toArray(new String[0])) + .param("subgroups", subgroups.toArray(new String[0])) + .contentType("application/json") + ).andDo(print()) + .andExpect(expectedStatus) + .andReturn(); + } + + /** + * method send GET request to ExamController at /pkwmtt/api/v1/exams/exam-types URI. + * Then check if response code is the same as expected + * + * @param expectedStatus expectedStatus status().[http response] (example: status().isOk() ) + * @return MvcResult object which could be used to capture response body + */ + private MvcResult assertGetExamTypesRequest(ResultMatcher expectedStatus) throws Exception { + return mockMvc.perform(MockMvcRequestBuilders + .get("/pkwmtt/api/v1/exams/exam-types") + .contentType("application/json") + ).andDo(print()) + .andExpect(expectedStatus) + .andReturn(); + } + +// + +} \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/examCalendar/ExamServiceTest.java b/src/test/java/org/pkwmtt/examCalendar/ExamServiceTest.java new file mode 100644 index 0000000..fd4edf7 --- /dev/null +++ b/src/test/java/org/pkwmtt/examCalendar/ExamServiceTest.java @@ -0,0 +1,803 @@ +package org.pkwmtt.examCalendar; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.pkwmtt.examCalendar.dto.ExamDto; +import org.pkwmtt.examCalendar.entity.Exam; +import org.pkwmtt.examCalendar.entity.ExamType; +import org.pkwmtt.examCalendar.entity.StudentGroup; +import org.pkwmtt.examCalendar.repository.ExamRepository; +import org.pkwmtt.examCalendar.repository.ExamTypeRepository; +import org.pkwmtt.examCalendar.repository.GroupRepository; +import org.pkwmtt.exceptions.*; +import org.pkwmtt.timetable.TimetableService; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + + +@ExtendWith(MockitoExtension.class) +class ExamServiceTest { + + @Mock + private ExamRepository examRepository; + + @Mock + private GroupRepository groupRepository; + + @Mock + private ExamTypeRepository examTypeRepository; + + @Mock + private TimetableService timetableService; + + @InjectMocks + private ExamService examService; + + // + + /** + * test specification + * generalGroup - 1 item + * subgroup - blank + * timetable service - available + * provided groups - match groups from timetable service + * groupRepository - don't contain provided groups + */ + @Test + void testBlankSubgroupAndMoreArgumentsThatRequiredReturnedByService() { +// given + Set g12K2 = Set.of("12K2"); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = ExamDto.builder() + .title("title") + .description("description") + .date(date) + .examType("exam") + .generalGroups(new HashSet<>(g12K2)) + .build(); + ExamType examType = buildExampleExamType(); + List studentGroups = buildExampleStudentGroupList(g12K2); + Exam exam = buildExamWithIdAndGroups(1, studentGroups); + + when(examTypeRepository.findByName(examDto.getExamType())).thenReturn(Optional.of(examType)); +// more groups than in set + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(List.of("12K1", "12K2", "12K3"))); + when(groupRepository.findAllByNameIn(g12K2)).thenReturn(new HashSet<>(Set.of())); + when(groupRepository.saveAll(anyList())).thenReturn(studentGroups); + when(examRepository.save(any(Exam.class))).thenReturn(exam); +// when + int savedId = examService.addExam(examDto); +// then + verify(examTypeRepository, times(1)).findByName(examDto.getExamType()); + verify(timetableService, times(1)).getGeneralGroupList(); + verify(groupRepository, times(1)).findAllByNameIn(g12K2); + + @SuppressWarnings("unchecked") + ArgumentCaptor> groupCaptor = ArgumentCaptor.forClass(List.class); + verify(groupRepository, times(1)).saveAll(groupCaptor.capture()); + assertEquals("12K2", groupCaptor.getValue().getFirst().getName()); + + ArgumentCaptor examCaptor = ArgumentCaptor.forClass(Exam.class); + verify(examRepository, times(1)).save(examCaptor.capture()); + Exam savedExam = examCaptor.getValue(); + assertExam(savedExam, date, savedId, g12K2); + } + + /** + * test specification + * generalGroup - 3 item + * subgroup - 0 items + * timetable service - available + * provided groups - match groups from timetable service + * groupRepository - don't contain provided groups + */ + @Test + void addExamForMultipleGeneralGroupsWithEmptySubgroups() { + // given + Set generalGroups = Set.of("12K1", "12K2", "12K3"); + Set subgroups = Set.of(); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + ExamType examType = buildExampleExamType(); + List studentGroups = buildExampleStudentGroupList(generalGroups); + Exam exam = buildExamWithIdAndGroups(1, studentGroups); + + when(examTypeRepository.findByName(examDto.getExamType())).thenReturn(Optional.of(examType)); + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(generalGroups)); + + when(groupRepository.findAllByNameIn(generalGroups)).thenReturn(new HashSet<>(Set.of())); + when(groupRepository.saveAll(anyList())).thenReturn(studentGroups); + when(examRepository.save(any(Exam.class))).thenReturn(exam); +// when + int savedId = examService.addExam(examDto); +// then + verify(examTypeRepository, times(1)).findByName(examDto.getExamType()); + verify(timetableService, times(1)).getGeneralGroupList(); + verify(groupRepository, times(1)).findAllByNameIn(generalGroups); + + @SuppressWarnings("unchecked") + ArgumentCaptor> groupCaptor = ArgumentCaptor.forClass(List.class); + verify(groupRepository, times(1)).saveAll(groupCaptor.capture()); + Set capturedGroups = groupCaptor.getValue().stream().map(StudentGroup::getName).collect(Collectors.toSet()); + assertEquals(generalGroups, capturedGroups); + + ArgumentCaptor examCaptor = ArgumentCaptor.forClass(Exam.class); + verify(examRepository, times(1)).save(examCaptor.capture()); + Exam savedExam = examCaptor.getValue(); + assertExam(savedExam, date, savedId, generalGroups); + } + + + /** + * test specification + * generalGroup - 3 item + * subgroup - 2 items + * timetable service - available + * provided groups - match groups from timetable service + * groupRepository - don't contain provided groups + */ + @Test + void shouldThrowWhenThereAreMoreThan1GeneralGroupsAndSubgroupsIsPresent() { + // given + LocalDateTime date = LocalDateTime.now().plusDays(1); + Set generalGroups = Set.of("12K1", "12K2", "12K3"); + Set subgroups = Set.of("L04", "L05"); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(generalGroups)); + RuntimeException exception = assertThrows(InvalidGroupIdentifierException.class, () -> examService.addExam(examDto)); + assertEquals("Invalid group identifier: ambiguous general groups for subgroups", exception.getMessage()); + } + + /** + * test specification + * generalGroup - 1 item + * subgroup - 1 items + * timetable service - available + * provided groups - match groups from timetable service + * groupRepository - don't contain provided groups + */ + @Test + void addExamForSingleGeneralGroupAndSingleSubgroup() throws JsonProcessingException { + // given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of("K04"); + when(timetableService.getAvailableSubGroups(any(String.class))).thenReturn(new ArrayList<>(List.of("K03", "K04", "L04"))); + testExamServiceForSubgroups(generalGroups, subgroups); + } + + /** + * test specification + * generalGroup - 1 item + * subgroup - 4 items + * timetable service - available + * provided groups - match groups from timetable service + * groupRepository - don't contain provided groups + */ + @Test + void addExamForSingleGeneralGroupAndMultipleSubgroup() throws JsonProcessingException { + // given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of("K04", "P04", "L04", "L03"); + when(timetableService.getAvailableSubGroups(any(String.class))).thenReturn(new ArrayList<>(List.of("K03", "K04", "P04", "L04", "L03"))); + testExamServiceForSubgroups(generalGroups, subgroups); + } + + + /** + * test specification + * generalGroup - 0 item + * subgroup - 1 items + * timetable service - available + * provided groups - match groups from timetable service + * groupRepository - don't contain provided groups + */ + @Test + void addExamForEmptyGeneralGroup() { + // given + Set generalGroups = Set.of(); + Set subgroups = Set.of("K04"); + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + RuntimeException exception = assertThrows(InvalidGroupIdentifierException.class, () -> examService.addExam(examDto)); + assertEquals("Invalid group identifier: general group is missing", exception.getMessage()); + } + + // + + // + + /** + * test specification + * generalGroup - 2 item + * subgroup - 0 items + * timetable service - available + * provided groups - don't match groups from timetable service + * groupRepository - don't contain provided groups + */ + @Test + void shouldThrowWhenGeneralGroupsDontMatchService() { + // given + Set generalGroups = Set.of("12K1", "12K2"); + Set subgroups = Set.of(); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(List.of())); +// when + RuntimeException exception = assertThrows(InvalidGroupIdentifierException.class, () -> examService.addExam(examDto)); +// then + assertEquals("Invalid group identifiers: [12K1, 12K2]", exception.getMessage()); + } + + @Test + void shouldThrowWhenNotAllGeneralGroupsMatchService() { + // given + Set generalGroups = Set.of("12K1", "12K2"); + Set subgroups = Set.of(); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(List.of("12K1"))); +// when + RuntimeException exception = assertThrows(InvalidGroupIdentifierException.class, () -> examService.addExam(examDto)); +// then + assertEquals("Invalid group identifiers: [12K2]", exception.getMessage()); + } + + /** + * test specification + * generalGroup - 1 item + * subgroup - 3 items + * timetable service - available + * provided groups - partially match groups from timetable service + * groupRepository - don't contain provided groups + */ + @Test + void shouldThrowWhenSubgroupsDontMatchService() throws JsonProcessingException { + // given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of("K04", "P04", "L04"); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(List.of("12K2"))); + when(timetableService.getAvailableSubGroups("12K2")).thenReturn(List.of("K05")); +// when + RuntimeException exception = assertThrows(InvalidGroupIdentifierException.class, () -> examService.addExam(examDto)); +// then + String message = exception.getMessage(); + assertTrue(message.startsWith("Invalid group identifiers:")); + assertFalse(message.contains("12K2")); + assertTrue(message.contains("K04")); + assertTrue(message.contains("P04")); + assertTrue(message.contains("L04")); + assertFalse(message.contains("K05")); + } + + @Test + void shouldThrowWhenNotAllSubgroupsMatchService() throws JsonProcessingException { + // given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of("K04", "P04", "L04"); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(List.of("12K2"))); + when(timetableService.getAvailableSubGroups("12K2")).thenReturn(List.of("P04", "L04", "K05")); +// when + RuntimeException exception = assertThrows(InvalidGroupIdentifierException.class, () -> examService.addExam(examDto)); +// then + String message = exception.getMessage(); + assertTrue(message.startsWith("Invalid group identifiers:")); + assertFalse(message.contains("12K2")); + assertTrue(message.contains("K04")); + assertFalse(message.contains("P04")); + assertFalse(message.contains("L04")); + assertFalse(message.contains("K05")); + } + + // + + // + + /** + * test specification + * generalGroup - 1 item + * subgroup - 0 items + * timetable service - available + * provided groups - match groups from timetable service + * groupRepository - contain provided groups + */ + @Test + void addExamForSingleGeneralGroupWithRepositoryContainingGroup() { + // given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of(); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + ExamType examType = buildExampleExamType(); + List studentGroups = buildExampleStudentGroupList(generalGroups); + Exam exam = buildExamWithIdAndGroups(1, studentGroups); + + when(examTypeRepository.findByName(examDto.getExamType())).thenReturn(Optional.of(examType)); + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(generalGroups)); + + when(groupRepository.findAllByNameIn(generalGroups)).thenReturn(new HashSet<>(studentGroups)); + when(groupRepository.saveAll(any())).thenReturn(List.of()); + when(examRepository.save(any(Exam.class))).thenReturn(exam); +// when + int savedId = examService.addExam(examDto); +// then + verify(examTypeRepository, times(1)).findByName(examDto.getExamType()); + verify(timetableService, times(1)).getGeneralGroupList(); + verify(groupRepository, times(1)).findAllByNameIn(any()); //??? + verify(groupRepository, times(1)).saveAll(List.of()); + + ArgumentCaptor examCaptor = ArgumentCaptor.forClass(Exam.class); + verify(examRepository, times(1)).save(examCaptor.capture()); + Exam savedExam = examCaptor.getValue(); + assertExam(savedExam, date, savedId, generalGroups); + } + + /** + * test specification + * generalGroup - 1 item + * subgroup - 4 items + * timetable service - available + * provided groups - match groups from timetable service + * groupRepository - partially contain provided groups + */ + @Test + void addExamForSingleGeneralGroupAndSubgroupsWithRepositoryContainingGroups() throws JsonProcessingException { + // given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of("K04", "P04", "L04", "K05"); + Set combinedGroups = Set.of("12K", "K04", "P04", "L04", "K05"); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + ExamType examType = buildExampleExamType(); + List studentGroups = buildExampleStudentGroupList(combinedGroups); + Exam exam = buildExamWithIdAndGroups(1, studentGroups); + + when(examTypeRepository.findByName(examDto.getExamType())).thenReturn(Optional.of(examType)); + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(generalGroups)); + when(timetableService.getAvailableSubGroups("12K2")).thenReturn(List.of("K04", "P04", "L04", "K05")); + + //noinspection unchecked + when(groupRepository.findAllByNameIn(any(Set.class))).thenReturn(new HashSet<>(studentGroups.subList(0, 3))); + when(groupRepository.saveAll(any())).thenReturn(studentGroups.subList(3, 5)); + when(examRepository.save(any(Exam.class))).thenReturn(exam); +// when + int savedId = examService.addExam(examDto); +// then + verify(examTypeRepository, times(1)).findByName(examDto.getExamType()); + verify(timetableService, times(1)).getGeneralGroupList(); + verify(groupRepository, times(1)).findAllByNameIn(any()); + verify(groupRepository, times(1)).saveAll(any()); + + ArgumentCaptor examCaptor = ArgumentCaptor.forClass(Exam.class); + verify(examRepository, times(1)).save(examCaptor.capture()); + Exam savedExam = examCaptor.getValue(); + assertExam(savedExam, date, savedId, combinedGroups); + } + + // + + // + + /** + * test specification + * generalGroup - 1 item + * subgroup - 0 item + * timetable service - unavailable + * provided groups - match groups from timetable service + * groupRepository - don't contain provided groups + */ + @Test + void unavailableServiceAndRepositoryDontMatch() { +// given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of(); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + +// more groups than in set + when(timetableService.getGeneralGroupList()).thenThrow(new WebPageContentNotAvailableException()); + when(groupRepository.findAllByNameIn(generalGroups)).thenReturn(new HashSet<>(Set.of())); +// when + RuntimeException exception = assertThrows(ServiceNotAvailableException.class, () -> examService.addExam(examDto)); +// then + assertEquals("Couldn't verify groups using repository", exception.getMessage()); + verify(timetableService, times(1)).getGeneralGroupList(); + verify(groupRepository, times(1)).findAllByNameIn(generalGroups); + } + + + /** + * test specification + * generalGroup - 1 item + * subgroup - 3 items + * timetable service - unavailable + * provided groups - match groups from timetable service + * groupRepository - partially contain provided groups + */ + @Test + void unavailableServiceAndRepositoryDontMatchForSubgroups() throws JsonProcessingException { +// given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of("L04", "K04", "P04"); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + List studentGroups = buildExampleStudentGroupList(Set.of("12K2", "L04")); + +// more groups than in set + when(timetableService.getGeneralGroupList()).thenThrow(new WebPageContentNotAvailableException()); + when(timetableService.getAvailableSubGroups("12K2")).thenThrow(new WebPageContentNotAvailableException()); + when(groupRepository.findAllByNameIn(any())).thenReturn(new HashSet<>(studentGroups)); +// when + RuntimeException exception = assertThrows(ServiceNotAvailableException.class, () -> examService.addExam(examDto)); +// then + assertEquals("Couldn't verify groups using timetable service", exception.getMessage()); + verify(timetableService, times(1)).getGeneralGroupList(); + verify(groupRepository, times(1)).findAllByNameIn(generalGroups); + } + + // + + // + + /** + * test specification + * generalGroup - 2 item + * subgroup - 0 item + * timetable service - unavailable + * provided groups - match groups from timetable service + * groupRepository - contain provided groups + */ + @Test + void addExamWhenServiceIsUnavailableAndRepositoryContainsGeneralGroups() { + // given + Set generalGroups = Set.of("12K1", "12K2"); + Set subgroups = Set.of(); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + ExamType examType = buildExampleExamType(); + List studentGroups = buildExampleStudentGroupList(generalGroups); + Exam exam = buildExamWithIdAndGroups(1, studentGroups); + + when(examTypeRepository.findByName(examDto.getExamType())).thenReturn(Optional.of(examType)); + when(timetableService.getGeneralGroupList()).thenThrow(new WebPageContentNotAvailableException()); + + when(groupRepository.findAllByNameIn(generalGroups)).thenReturn(new HashSet<>(studentGroups)); + when(groupRepository.saveAll(anyList())).thenReturn(studentGroups); + when(examRepository.save(any(Exam.class))).thenReturn(exam); +// when + int savedId = examService.addExam(examDto); +// then + verify(examTypeRepository, times(1)).findByName(examDto.getExamType()); + verify(timetableService, times(1)).getGeneralGroupList(); + verify(groupRepository, times(2)).findAllByNameIn(any()); + verify(groupRepository, times(1)).saveAll(any()); + + ArgumentCaptor examCaptor = ArgumentCaptor.forClass(Exam.class); + verify(examRepository, times(1)).save(examCaptor.capture()); + Exam savedExam = examCaptor.getValue(); + assertExam(savedExam, date, savedId, generalGroups); + } + + /** + * test specification + * generalGroup - 1 item + * subgroup - 4 items + * timetable service - unavailable + * provided groups - match groups from timetable service + * groupRepository - contain provided groups + */ + @Test + @Disabled("Not supported yet") + void addExamWhenServiceIsUnavailableAndRepositoryContainsGroups() throws JsonProcessingException { + // given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of("L04", "K04", "P04", "K05"); + Set combinedGroups = Set.of("12K2", "L04", "K04", "P04", "K05"); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + ExamType examType = buildExampleExamType(); + List studentGroups = buildExampleStudentGroupList(combinedGroups); + Exam exam = buildExamWithIdAndGroups(1, studentGroups); + + when(examTypeRepository.findByName(examDto.getExamType())).thenReturn(Optional.of(examType)); + when(timetableService.getGeneralGroupList()).thenThrow(new WebPageContentNotAvailableException()); + when(timetableService.getAvailableSubGroups("12K2")).thenThrow(new JsonParseException("parsing subgroups failed")); + + //noinspection unchecked + when(groupRepository.findAllByNameIn(any(Set.class))).thenReturn(new HashSet<>(studentGroups)); + when(groupRepository.saveAll(anyList())).thenReturn(List.of()); + when(examRepository.save(any(Exam.class))).thenReturn(exam); +// when + int savedId = examService.addExam(examDto); +// then + verify(examTypeRepository, times(1)).findByName(examDto.getExamType()); + verify(timetableService, times(1)).getGeneralGroupList(); + verify(groupRepository, times(2)).findAllByNameIn(any()); + verify(groupRepository, times(1)).saveAll(any()); + + ArgumentCaptor examCaptor = ArgumentCaptor.forClass(Exam.class); + verify(examRepository, times(1)).save(examCaptor.capture()); + Exam savedExam = examCaptor.getValue(); + assertExam(savedExam, date, savedId, generalGroups); + } + + // + + /************************************************************************************/ +//modify exam + @Test + void shouldModifyExamWhenIdExists() { + + } + + @Test + void shouldThrowWhenExamIdNotExists() { + // given + + } + + /************************************************************************************/ +//delete exam + @Test + void shouldDeleteExamWhenIdExists() { +// given + int examId = 1; + when(examRepository.findById(examId)).thenReturn(Optional.of(mock(Exam.class))); +// when + examService.deleteExam(examId); +// then + verify(examRepository).deleteById(examId); + } + + @Test + void shouldThrowExceptionWhenExamIdNotExists() { +// given + int examId = 5; + when(examRepository.findById(examId)).thenThrow(new NoSuchElementException("Exam not found")); +// when + RuntimeException exception = assertThrows( + NoSuchElementException.class, + () -> examService.deleteExam(examId) + ); +// then + verify(examRepository, never()).deleteById(examId); + assertEquals("Exam not found", exception.getMessage()); + } + + /************************************************************************************/ +// getExamById + @Test + void getExamById() { +// given + int examId = 1; + when(examRepository.findById(examId)).thenReturn(Optional.of(mock(Exam.class))); +// when + Exam exam = examService.getExamById(examId); +// then + verify(examRepository).findById(examId); + assertNotNull(exam); + } + + @Test + void shouldThrowExceptionWhenExamNotFound() { +// given + int examId = 5; + when(examRepository.findById(examId)).thenThrow(new NoSuchElementException("Exam not found")); +// when + RuntimeException exception = assertThrows( + NoSuchElementException.class, + () -> examService.getExamById(examId) + ); +// then + assertEquals("Exam not found", exception.getMessage()); + } + + // getExamByGroup + + @Test + void getExamsForNormalGroups() { +// given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of("L04", "K04", "P04"); +// when + examService.getExamByGroups(generalGroups, subgroups); +// then + verify(examRepository, times(1)).findAllByGroups_NameIn(generalGroups); + verify(examRepository, times(1)).findAllBySubgroupsOfGeneralGroup("12K", subgroups); + } + + @Test + void getExamsForGroupWithoutDigitAsFirstCharacter() { +// given + Set generalGroups = Set.of("1Er"); + Set subgroups = Set.of("L01", "K01", "P01"); +// when + examService.getExamByGroups(generalGroups, subgroups); +// then + verify(examRepository, times(1)).findAllByGroups_NameIn(generalGroups); + verify(examRepository, times(1)).findAllBySubgroupsOfGeneralGroup("1Er", subgroups); + } + + @Test + void getExamsWithEmptySubgroups() { +// given + Set generalGroups = Set.of("12K2"); + Set subgroups = Set.of(); +// when + examService.getExamByGroups(generalGroups, subgroups); +// then + verify(examRepository, times(1)).findAllByGroups_NameIn(generalGroups); + verify(examRepository, never()).findAllBySubgroupsOfGeneralGroup(any(), any()); + } + + @Test + void getExamsWithBlankSubgroups() { +// given + Set generalGroups = Set.of("12K2"); + Set subgroups = null; +// when + examService.getExamByGroups(generalGroups, subgroups); +// then + verify(examRepository, times(1)).findAllByGroups_NameIn(generalGroups); + verify(examRepository, never()).findAllBySubgroupsOfGeneralGroup(any(), any()); + } + + @Test + void shouldNotThrowWhenGroupsAreFromTheSameYearOfStudy() { +// given + Set generalGroups = Set.of("12K1", "12K2"); + Set subgroups = Set.of("L01", "K01", "P01"); +// when + examService.getExamByGroups(generalGroups, subgroups); +// then + verify(examRepository, times(1)).findAllByGroups_NameIn(generalGroups); + verify(examRepository, times(1)).findAllBySubgroupsOfGeneralGroup("12K", subgroups); + } + + @Test + void shouldThrowWhenSubgroupsAreSwappedWithGeneralGroups() { +// given + Set generalGroups = new HashSet<>(Set.of("L01", "K01", "P01")); + Set subgroups = new HashSet<>( Set.of("12K1")); +// when then + assertThrows( + SpecifiedGeneralGroupDoesntExistsException.class, + () -> examService.getExamByGroups(generalGroups, subgroups) + ); + } + + @Test + void shouldThrowWhenSubgroupsAreTheGeneralGroups() { +// given + Set generalGroups = new HashSet<>(Set.of("12K1")); + Set subgroups = new HashSet<>( Set.of("12K1", "12K2", "12K3")); +// when, then + assertThrows( + SpecifiedSubGroupDoesntExistsException.class, + () -> examService.getExamByGroups(generalGroups, subgroups) + ); + } + + @Test + void shouldThrowWhenGeneralGroupsAreFromDifferentYearOfStudy() { +// given + Set generalGroups = Set.of("12K1", "12A2"); + Set subgroups = Set.of("L01", "K01", "P01"); +// when + RuntimeException exception = assertThrows(InvalidGroupIdentifierException.class, () -> examService.getExamByGroups(generalGroups, subgroups)); +// then + assertEquals("Invalid group identifier: ambiguous superior group identifier for subgroups", exception.getMessage()); + } + + + + private static List buildExampleStudentGroupList(Set groupNames) { + AtomicInteger id = new AtomicInteger(); + return groupNames.stream() + .map(g -> StudentGroup.builder() + .groupId(id.getAndIncrement()) + .name(g) + .build() + ).collect(Collectors.toList()); + } + + private static Exam buildExamWithIdAndGroups(int id, List groups) { + return Exam.builder() + .examId(id) + .groups(new HashSet<>(groups)) + .build(); + } + + private static ExamType buildExampleExamType() { + return ExamType.builder() + .examTypeId(1) + .name("exam") + .build(); + } + + private static ExamDto buildExampleExamDto(Set generalGroups, Set subgroups, LocalDateTime date) { + return ExamDto.builder() + .title("title") + .description("description") + .date(date) + .examType("exam") + .generalGroups(new HashSet<>(generalGroups)) + .subgroups(new HashSet<>(subgroups)) + .build(); + } + + private static void assertExam(Exam savedExam, LocalDateTime date, int savedId, Set groups) { + assertEquals("title", savedExam.getTitle()); + assertEquals("description", savedExam.getDescription()); + assertEquals(date, savedExam.getExamDate()); + assertEquals("exam", savedExam.getExamType().getName()); + assertEquals(groups.size(), savedExam.getGroups().size()); + assertEquals(groups, savedExam.getGroups().stream().map(StudentGroup::getName).collect(Collectors.toSet())); + assertEquals(1, savedId); + } + + private void testExamServiceForSubgroups(Set generalGroups, Set subgroups) { + Set combinedGroups = new HashSet<>(subgroups); + combinedGroups.addAll(generalGroups.stream() + .map(g -> g.matches(".*\\d$") ? g.substring(0, g.length() - 1) : g) + .collect(Collectors.toSet())); + + LocalDateTime date = LocalDateTime.now().plusDays(1); + ExamDto examDto = buildExampleExamDto(generalGroups, subgroups, date); + ExamType examType = buildExampleExamType(); + List studentGroups = buildExampleStudentGroupList(combinedGroups); + Exam exam = buildExamWithIdAndGroups(1, studentGroups); + + when(examTypeRepository.findByName(examDto.getExamType())).thenReturn(Optional.of(examType)); + when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(generalGroups)); + + when(groupRepository.findAllByNameIn(combinedGroups)).thenReturn(new HashSet<>(Set.of())); + when(groupRepository.saveAll(anyList())).thenReturn(studentGroups); + when(examRepository.save(any(Exam.class))).thenReturn(exam); +// when + int savedId = examService.addExam(examDto); +// then + verify(examTypeRepository, times(1)).findByName(examDto.getExamType()); + verify(timetableService, times(1)).getGeneralGroupList(); + verify(groupRepository, times(1)).findAllByNameIn(combinedGroups); + + @SuppressWarnings("unchecked") + ArgumentCaptor> groupCaptor = ArgumentCaptor.forClass(List.class); + verify(groupRepository, times(1)).saveAll(groupCaptor.capture()); + Set capturedGroups = groupCaptor.getValue().stream().map(StudentGroup::getName).collect(Collectors.toSet()); + assertEquals(combinedGroups, capturedGroups); + + ArgumentCaptor examCaptor = ArgumentCaptor.forClass(Exam.class); + verify(examRepository, times(1)).save(examCaptor.capture()); + Exam savedExam = examCaptor.getValue(); + assertExam(savedExam, date, savedId, combinedGroups); + } +} \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/examCalendar/dto/ExamDtoTest.java b/src/test/java/org/pkwmtt/examCalendar/dto/ExamDtoTest.java new file mode 100644 index 0000000..a98fd0e --- /dev/null +++ b/src/test/java/org/pkwmtt/examCalendar/dto/ExamDtoTest.java @@ -0,0 +1,230 @@ +package org.pkwmtt.examCalendar.dto; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.pkwmtt.examCalendar.entity.StudentGroup; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExamDtoTest { + + private final Validator validator; + + public ExamDtoTest() { + this.validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + @Test + void shouldSuccessWithCompleteData() { +// given + ExamDto examDto = ExamDto.builder() + .title("Math exam") + .description("First exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); +// when, then + assertTrue(validator.validate(examDto).isEmpty()); + } + + @Test + void shouldSuccessWithEmptyDescription() { +// given + ExamDto examDto = ExamDto.builder() + .title("Math exam") + .description("") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); +// when, then + assertTrue(validator.validate(examDto).isEmpty()); + } + + @Test + void shouldSuccessWithBlankDescription() { +// given + ExamDto examDto = ExamDto.builder() + .title("Math exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); +// when, then + assertTrue(validator.validate(examDto).isEmpty()); + } + + @Test + void shouldSuccessWithBlankSubgroups() { +// given + ExamDto examDto = ExamDto.builder() + .title("Math exam") + .description("First exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .build(); +// when, then + assertTrue(validator.validate(examDto).isEmpty()); + } + + @Test + void shouldSuccessWithEmptySubgroups() { +// given + ExamDto examDto = ExamDto.builder() + .title("Math exam") + .description("First exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("")) + .build(); +// when, then + assertTrue(validator.validate(examDto).isEmpty()); + } + + + // empty Strings + @Test + void shouldFailWithEmptyTitle() { + // given + ExamDto examDto = ExamDto.builder() + .title("") + .description("First exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("")) + .build(); +// when + Set> violations = validator.validate(examDto); +// then + assertFalse(validator.validate(examDto).isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("title"))); + } + + @Test + void shouldFailWithBlankTitle() { + // given + ExamDto examDto = ExamDto.builder() + .description("First exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("")) + .build(); +// when + Set> violations = validator.validate(examDto); +// then + assertFalse(validator.validate(examDto).isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("title"))); + } + + @Test + void shouldFailWithEmptyGeneralGroups() { + // given + ExamDto examDto = ExamDto.builder() + .title("Math exam") + .description("First exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of()) + .subgroups(Set.of("L04")) + .build(); +// when + Set> violations = validator.validate(examDto); +// then + assertFalse(validator.validate(examDto).isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("generalGroups"))); + } + + @Test + void shouldFailWithBlankGeneralGroups() { + // given + ExamDto examDto = ExamDto.builder() + .title("Math exam") + .description("First exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .subgroups(Set.of("L04")) + .build(); +// when + Set> violations = validator.validate(examDto); +// then + assertFalse(validator.validate(examDto).isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("generalGroups"))); + } + +// to long Strings + + @Test + void ShouldFailWithTooLongTitle() { + // given + ExamDto examDto = ExamDto.builder() +// 256 characters + .title("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .description("First exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); +// when + Set> violations = validator.validate(examDto); +// then + assertFalse(validator.validate(examDto).isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("title"))); + } + + @Test + void toLongDescription() { + // given + ExamDto examDto = ExamDto.builder() +// 256 characters + .title("Math exam") + .description("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .date(LocalDateTime.now().plusDays(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); +// when + Set> violations = validator.validate(examDto); +// then + assertFalse(validator.validate(examDto).isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("description"))); + } + + @Test + void dateNotInFuture() { + // given + ExamDto examDto = ExamDto.builder() +// 256 characters + .title("Math exam") + .description("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .date(LocalDateTime.now().minusHours(1)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); + // when + Set> violations = validator.validate(examDto); +// then + assertFalse(validator.validate(examDto).isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("date"))); + } + +} \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/examCalendar/entity/ExamTest.java b/src/test/java/org/pkwmtt/examCalendar/entity/ExamTest.java new file mode 100644 index 0000000..d813ede --- /dev/null +++ b/src/test/java/org/pkwmtt/examCalendar/entity/ExamTest.java @@ -0,0 +1,87 @@ +package org.pkwmtt.examCalendar.entity; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.pkwmtt.exceptions.UnsupportedCountOfArgumentsException; + +import java.time.LocalDateTime; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * tests of custom Exam builder + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ExamTest { + + ExamType examType; + Set studentGroups; + LocalDateTime date; + + @BeforeAll + void setup(){ + examType = ExamType.builder().name("project").build(); + studentGroups = Set.of(StudentGroup.builder().name("12K2").build()); + date = LocalDateTime.now().plusDays(1); + + } + + @Test + void shouldBuildExamWithCorrectData() { + Exam exam = Exam.builder() + .title("title") + .description("description") + .examDate(date) + .examType(examType) + .groups(studentGroups) + .build(); + + assertEquals("title", exam.getTitle()); + assertEquals("description", exam.getDescription()); + assertEquals(date, exam.getExamDate()); + assertEquals(examType, exam.getExamType()); + assertEquals(studentGroups, exam.getGroups()); + } + + @Test + void shouldThrowWhenNoGroupsAssigned() { + assertThrows(UnsupportedCountOfArgumentsException.class, () -> Exam.builder() + .title("title") + .description("description") + .examDate(date) + .examType(examType) +// no exam groups specified + .build()); + } + + @Test + void shouldThrowWhenZeroGroupsAssigned() { + assertThrows(UnsupportedCountOfArgumentsException.class, () -> Exam.builder() + .title("title") + .description("description") + .examDate(date) + .examType(examType) + .groups(Set.of()) + .build()); + } + + @Test + void shouldThrowWhenToManyGroupsAssigned() { + Set longStudentGroups = IntStream.range(0, 101) + .mapToObj(i -> StudentGroup.builder().name("group" + i).build()) + .collect(Collectors.toSet()); + + assertThrows(UnsupportedCountOfArgumentsException.class, () -> Exam.builder() + .title("title") + .description("description") + .examDate(date) + .examType(examType) + .groups(longStudentGroups) + .build()); + } +} \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/examCalendar/repository/ExamRepositoryTest.java b/src/test/java/org/pkwmtt/examCalendar/repository/ExamRepositoryTest.java new file mode 100644 index 0000000..52490f4 --- /dev/null +++ b/src/test/java/org/pkwmtt/examCalendar/repository/ExamRepositoryTest.java @@ -0,0 +1,177 @@ +package org.pkwmtt.examCalendar.repository; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.pkwmtt.examCalendar.entity.Exam; +import org.pkwmtt.examCalendar.entity.ExamType; +import org.pkwmtt.examCalendar.entity.StudentGroup; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.EmbeddedDatabaseConnection; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Slf4j +@DataJpaTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2) +@ActiveProfiles("database") +class ExamRepositoryTest { + + @Autowired + private ExamRepository examRepository; + + @Autowired + private ExamTypeRepository examTypeRepository; + + @Autowired + private GroupRepository groupRepository; + + @BeforeAll + void setUp() { + ExamType examType = ExamType.builder() + .name("exam").build(); + examTypeRepository.save(examType); + + StudentGroup g12A = StudentGroup.builder() + .name("12A").build(); + StudentGroup g12A1 = StudentGroup.builder() + .name("12A1").build(); + StudentGroup g12A2 = StudentGroup.builder() + .name("12A2").build(); + + StudentGroup g12K = StudentGroup.builder() + .name("12K").build(); + StudentGroup g12K1 = StudentGroup.builder() + .name("12K1").build(); + StudentGroup g12K2 = StudentGroup.builder() + .name("12K2").build(); + StudentGroup g12K3 = StudentGroup.builder() + .name("12K3").build(); + StudentGroup gL04 = StudentGroup.builder() + .name("L04").build(); + StudentGroup gL05 = StudentGroup.builder() + .name("L05").build(); + + groupRepository.save(g12A); + groupRepository.save(g12A1); + groupRepository.save(g12A2); + + groupRepository.save(g12K); + groupRepository.save(g12K1); + groupRepository.save(g12K2); + groupRepository.save(g12K3); + groupRepository.save(gL04); + groupRepository.save(gL05); + + Exam smallGroupExam1 = Exam.builder() + .title("small Group Exam 1") + .description("Linear Algebra") + .examDate(LocalDateTime.now().plusDays(1)) + .examType(examType) + .groups(Set.of(g12K, gL04)) + .build(); + + Exam smallGroupExam2 = Exam.builder() + .title("small Group Exam 2") + .description("Linear Algebra") + .examDate(LocalDateTime.now().plusDays(1)) + .examType(examType) + .groups(Set.of(gL04, g12K, gL05)) + .build(); + + Exam smallGroupExam3 = Exam.builder() + .title("small Group Exam 3") + .description("Linear Algebra") + .examDate(LocalDateTime.now().plusDays(1)) + .examType(examType) + .groups(Set.of(g12A, gL05)) + .build(); + + Exam generalGroupExam1 = Exam.builder() + .title("general Group Exam 1") + .description("Linear Algebra") + .examDate(LocalDateTime.now().plusDays(1)) + .examType(examType) + .groups(Set.of(g12K1, g12K2, g12K3)) + .build(); + + Exam generalGroupExam2 = Exam.builder() + .title("general Group Exam 2") + .description("Linear Algebra") + .examDate(LocalDateTime.now().plusDays(1)) + .examType(examType) + .groups(Set.of(g12K1)) + .build(); + + Exam generalGroupExam3 = Exam.builder() + .title("general Group Exam 3") + .description("Linear Algebra") + .examDate(LocalDateTime.now().plusDays(1)) + .examType(examType) + .groups(Set.of(g12A1, g12A2)) + .build(); + + examRepository.save(smallGroupExam1); + examRepository.save(smallGroupExam2); + examRepository.save(smallGroupExam3); + examRepository.save(generalGroupExam1); + examRepository.save(generalGroupExam2); + examRepository.save(generalGroupExam3); + } + + @Test + void shouldReturnExamsWhenNotAllSubgroupsFromRepositoryMatched() { + Set exams = examRepository.findAllBySubgroupsOfGeneralGroup("12K", Set.of("L04")); + assertEquals(2, exams.size()); + List examTitles = exams.stream().map(Exam::getTitle).sorted().toList(); + assertEquals("small Group Exam 1", examTitles.get(0)); + assertEquals("small Group Exam 2", examTitles.get(1)); + } + + @Test + void shouldReturnExamWhenNotAllSubgroupsFromArgumentsMatchedAndNotReturnExamsForWrongGeneralGroup() { + Set exams = examRepository.findAllBySubgroupsOfGeneralGroup("12K", Set.of("L05")); + assertEquals(1, exams.size()); + List examTitles = exams.stream().map(Exam::getTitle).sorted().toList(); + assertEquals("small Group Exam 2", examTitles.getFirst()); + } + + @Test + void shouldReturnExamsWhenMultipleArgumentsMatch() { + Set exams = examRepository.findAllBySubgroupsOfGeneralGroup("12K", Set.of("L04", "L05")); + assertEquals(2, exams.size()); + Set examTitles = exams.stream().map(Exam::getTitle).collect(Collectors.toSet()); + assertTrue(examTitles.contains("small Group Exam 1")); + assertTrue(examTitles.contains("small Group Exam 2")); + } + + @Test + void ShouldReturnEmptyListWhenSubgroupsSetIsEmpty() { + Set exams = examRepository.findAllBySubgroupsOfGeneralGroup("12K", Set.of()); + assertTrue(exams.isEmpty()); + } + + @Test + void shouldReturnEmptyListWhenGeneralGroupIdentifierHasInvalidFormat() { + Set exams = examRepository.findAllBySubgroupsOfGeneralGroup("12K2", Set.of("L04")); + assertTrue(exams.isEmpty()); + } + + @Test + void shouldReturnEmptyListWhenGeneralGroupIdentifierDontMatch() { + Set exams = examRepository.findAllBySubgroupsOfGeneralGroup("12B", Set.of("L04", "L05")); + assertTrue(exams.isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/otp/OTPServiceTest.java b/src/test/java/org/pkwmtt/otp/OTPServiceTest.java new file mode 100644 index 0000000..9fa98fc --- /dev/null +++ b/src/test/java/org/pkwmtt/otp/OTPServiceTest.java @@ -0,0 +1,156 @@ +package org.pkwmtt.otp; + +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.ServerSetupTest; +import com.mysql.cj.exceptions.WrongArgumentException; +import jakarta.mail.Multipart; +import jakarta.mail.Part; +import jakarta.mail.internet.MimeMessage; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.pkwmtt.exceptions.OTPCodeNotFoundException; +import org.pkwmtt.exceptions.SpecifiedGeneralGroupDoesntExistsException; +import org.pkwmtt.exceptions.WrongOTPFormatException; +import org.pkwmtt.otp.dto.OTPRequest; +import org.pkwmtt.otp.repository.OTPCodeRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.EmbeddedDatabaseConnection; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@ActiveProfiles("database") +@SpringBootTest +@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2) +class OTPServiceTest { + + @Autowired + private OTPService otpService; + + @Autowired + private OTPCodeRepository otpCodeRepository; + + @RegisterExtension + static GreenMailExtension greenMail = new GreenMailExtension(ServerSetupTest.SMTP) + .withConfiguration(GreenMailConfiguration.aConfig().withUser("test@localhost", "test")) + .withPerMethodLifecycle(true); + + @Test + void shouldSendCorrectMailWithRepresentativePayload () { + //given + List requests = List.of(new OTPRequest("test@localhost", "12K")); + Pattern pattern = Pattern.compile("[A-Z0-9]{6}"); + //when + otpService.sendOTPCodesForManyGroups(requests); + + //then + assertAll(() -> { + assertTrue(greenMail.waitForIncomingEmail(1)); + + MimeMessage receivedMessage = greenMail.getReceivedMessages()[0]; + assertEquals("Kod Starosty 12K", receivedMessage.getSubject()); + assertEquals("test@localhost", receivedMessage.getAllRecipients()[0].toString()); + + Matcher matcher = pattern.matcher(Objects.requireNonNull(extractBody(receivedMessage))); + assertTrue(matcher.find()); + System.out.println(matcher.group(0)); + assertTrue(otpCodeRepository.existsOTPCodeByCode(matcher.group(0))); + }); + } + + @Test + void shouldThrow_WrongArgumentException () { + //given + List requests = List.of(new OTPRequest("test@localhost", "12K1")); + //when + //then + assertThrows(WrongArgumentException.class, () -> otpService.sendOTPCodesForManyGroups(requests)); + } + + @Test + void shouldThrow_SpecifiedGeneralGroupDoesntExistsException () { + //given + List requests = List.of(new OTPRequest("test@localhost", "XXXX")); + //when + //then + assertThrows(SpecifiedGeneralGroupDoesntExistsException.class, () -> otpService.sendOTPCodesForManyGroups(requests)); + } + + @Test + void shouldGenerateTokenForRepresentative () throws Exception { + //given + List requests = List.of(new OTPRequest("test@localhost", "12K")); + Pattern otpPattern = Pattern.compile("[A-Z0-9]{6}"); + Pattern tokenPattern = Pattern.compile("[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+"); + + //when + otpService.sendOTPCodesForManyGroups(requests); //generate mail with code + greenMail.waitForIncomingEmail(1); // fetch mail + + MimeMessage receivedMessage = greenMail.getReceivedMessages()[0]; + Matcher otpMatcher = otpPattern.matcher(Objects.requireNonNull(extractBody(receivedMessage))); //get content + + final String code; + if (otpMatcher.find()) { + code = otpMatcher.group(); + } else { + code = ""; + fail("Code not found"); + } + + String token = otpService.generateTokenForRepresentative(code); //generate token + + //then + assertAll(() -> { + assertNotNull(token); + + Matcher tokenMatcher = tokenPattern.matcher(token); + assertTrue(tokenMatcher.find()); + assertFalse(otpCodeRepository.existsOTPCodeByCode(code)); + }); + } + + @Test + void shouldThrow_WrongOTPFormatException_wrongCharacters () { + assertThrows(WrongOTPFormatException.class, () -> otpService.generateTokenForRepresentative("XXXXX#")); + } + + @Test + void shouldThrow_WrongOTPFormatException_tooLongCode () { + assertThrows(WrongOTPFormatException.class, () -> otpService.generateTokenForRepresentative("X".repeat(7))); + } + + @Test + void shouldThrow_OTPCodeNotFoundException () { + assertThrows(OTPCodeNotFoundException.class, () -> otpService.generateTokenForRepresentative("X".repeat(6))); + } + + private String extractBody (Part part) throws Exception { + if (part.isMimeType("text/plain") || part.isMimeType("text/html")) { + return (String) part.getContent(); + } + if (part.isMimeType("multipart/*")) { + Multipart mp = (Multipart) part.getContent(); + for (int i = 0; i < mp.getCount(); i++) { + String result = extractBody(mp.getBodyPart(i)); + if (result != null) { + return result; + } + } + } + return null; + } + +} \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/pkwmttbackend/PkwmttBackendApplicationTests.java b/src/test/java/org/pkwmtt/pkwmttbackend/PkwmttBackendApplicationTests.java deleted file mode 100644 index 2ae95fd..0000000 --- a/src/test/java/org/pkwmtt/pkwmttbackend/PkwmttBackendApplicationTests.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.pkwmtt.pkwmttbackend; - -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.junit.runners.Suite; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit4.SpringRunner; - -@SpringBootTest -class PkwmttBackendApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/org/pkwmtt/security/token/JwtServiceImplTest.java b/src/test/java/org/pkwmtt/security/token/JwtServiceImplTest.java new file mode 100644 index 0000000..9c50238 --- /dev/null +++ b/src/test/java/org/pkwmtt/security/token/JwtServiceImplTest.java @@ -0,0 +1,139 @@ +package org.pkwmtt.security.token; + +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pkwmtt.examCalendar.entity.User; +import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.security.token.dto.UserDTO; +import org.pkwmtt.security.token.utils.JwtUtils; + +import java.util.Base64; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JwtServiceImplTest { + + private JwtServiceImpl jwtService; + + @BeforeEach + void setUp() { + JwtUtils jwtUtils = mock(JwtUtils.class); + + byte[] keyBytes = new byte[32]; + for (int i = 0; i < 32; i++) keyBytes[i] = (byte) i; + String secretBase64 = Base64.getEncoder().encodeToString(keyBytes); + + when(jwtUtils.getSecret()).thenReturn(secretBase64); + when(jwtUtils.getExpirationMs()).thenReturn(1000L * 60 * 60 * 24 * 30 * 6); + + jwtService = new JwtServiceImpl(jwtUtils); + } + + @Test + void generateToken_shouldCreateNonEmptyToken() { + UserDTO user = new UserDTO() + .setEmail("user@example.com") + .setGroup("GROUP1") + .setRole(Role.ADMIN); + + String token = jwtService.generateToken(user); + assertNotNull(token); + assertFalse(token.isEmpty()); + } + + @Test + void getUserEmailFromToken_shouldReturnCorrectEmail() { + UserDTO user = new UserDTO() + .setEmail("user@example.com") + .setGroup("GROUP1") + .setRole(Role.ADMIN); + + String token = jwtService.generateToken(user); + String email = jwtService.getUserEmailFromToken(token); + assertEquals("user@example.com", email); + } + + @Test + void extractRoleFromToken_shouldReturnCorrectRole() { + UserDTO user = new UserDTO() + .setEmail("user@example.com") + .setGroup("GROUP1") + .setRole(Role.ADMIN); + + String token = jwtService.generateToken(user); + String roleClaim = jwtService.extractClaim(token, claims -> claims.get("role", String.class)); + assertEquals("ADMIN", roleClaim); + } + + @Test + void extractGroupFromToken_shouldReturnCorrectGroup() { + UserDTO user = new UserDTO() + .setEmail("user@example.com") + .setGroup("GROUP1") + .setRole(Role.ADMIN); + + String token = jwtService.generateToken(user); + String groupClaim = jwtService.extractClaim(token, claims -> claims.get("group", String.class)); + assertEquals("GROUP1", groupClaim); + } + + @Test + void validateToken_shouldReturnTrueForValidToken() { + UserDTO userDTO = new UserDTO() + .setEmail("user@example.com") + .setGroup("GROUP1") + .setRole(Role.ADMIN); + + String token = jwtService.generateToken(userDTO); + User mockUser = mock(User.class); + when(mockUser.getEmail()).thenReturn("user@example.com"); + assertTrue(jwtService.validateToken(token, mockUser)); + } + + @Test + void validateToken_shouldReturnFalseForInvalidEmail() { + UserDTO userDTO = new UserDTO() + .setEmail("user@example.com") + .setGroup("GROUP1") + .setRole(Role.ADMIN); + + String token = jwtService.generateToken(userDTO); + User mockUser = mock(User.class); + when(mockUser.getEmail()).thenReturn("other@example.com"); + assertFalse(jwtService.validateToken(token, mockUser)); + } + + @Test + void validateToken_shouldReturnFalseForExpiredToken() { + UserDTO user = new UserDTO() + .setEmail("user@example.com") + .setGroup("GROUP1") + .setRole(Role.ADMIN); + + long pastExpiration = System.currentTimeMillis() - 1000; + String expiredToken = Jwts.builder() + .subject(user.getEmail()) + .claim("group", user.getGroup()) + .claim("role", user.getRole()) + .issuedAt(new Date(System.currentTimeMillis() - 2000)) + .expiration(new Date(pastExpiration)) + .signWith(jwtService.decodeSecretKey()) + .compact(); + + User mockUser = mock(User.class); + when(mockUser.getEmail()).thenReturn("user@example.com"); + + assertFalse(jwtService.validateToken(expiredToken, mockUser)); + } + + @Test + void getUserEmailFromToken_shouldThrowExceptionForInvalidToken() { + String invalidToken = "invalid.token.value"; + assertThrows(JwtException.class, () -> jwtService.getUserEmailFromToken(invalidToken)); + } +} diff --git a/src/test/java/org/pkwmtt/security/token/filter/JwtFilterTest.java b/src/test/java/org/pkwmtt/security/token/filter/JwtFilterTest.java new file mode 100644 index 0000000..54123dc --- /dev/null +++ b/src/test/java/org/pkwmtt/security/token/filter/JwtFilterTest.java @@ -0,0 +1,58 @@ +package org.pkwmtt.security.token.filter; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.pkwmtt.examCalendar.entity.User; +import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.examCalendar.repository.UserRepository; +import org.pkwmtt.security.token.JwtService; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JwtFilterTest { + + private JwtService jwtService; + private UserRepository userRepository; + private JwtFilter jwtFilter; + + @BeforeEach + void setUp() { + jwtService = mock(JwtService.class); + userRepository = mock(UserRepository.class); + jwtFilter = new JwtFilter(); + jwtFilter.jwtService = jwtService; + jwtFilter.userRepository = userRepository; + + SecurityContextHolder.clearContext(); + } + + @Test + void givenValidToken_whenDoFilter_thenAuthenticationSet() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer validToken"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + User mockUser = mock(User.class); + when(mockUser.getRole()).thenReturn(Role.valueOf("ADMIN")); + when(mockUser.getEmail()).thenReturn("user@example.com"); + + when(jwtService.getUserEmailFromToken("validToken")).thenReturn("user@example.com"); + when(jwtService.validateToken(eq("validToken"), any(User.class))).thenReturn(true); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(mockUser)); + + jwtFilter.doFilterInternal(request, response, filterChain); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + } +} diff --git a/src/test/java/org/pkwmtt/timetable/TimetableCacheServiceTest.java b/src/test/java/org/pkwmtt/timetable/TimetableCacheServiceTest.java index f25888f..bef3add 100644 --- a/src/test/java/org/pkwmtt/timetable/TimetableCacheServiceTest.java +++ b/src/test/java/org/pkwmtt/timetable/TimetableCacheServiceTest.java @@ -2,47 +2,40 @@ import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.pkwmtt.ValuesForTest; import org.pkwmtt.cache.CacheInspector; import org.pkwmtt.timetable.dto.TimetableDTO; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; import test.TestConfig; -import java.util.List; import java.util.Map; -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.get; -import static com.github.tomakehurst.wiremock.client.WireMock.urlPathMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; @Slf4j +@SpringBootTest class TimetableCacheServiceTest extends TestConfig { @Autowired TimetableCacheService cachedService; - @Autowired - TimetableService service; - @Autowired CacheInspector cacheInspector; - + @BeforeEach - public void initWireMock() { - EXTERNAL_SERVICE_API_MOCK.stubFor(get(urlPathMatching("/plany/o25.html")) - .willReturn(aResponse() - .withStatus(200) - .withHeader("Content-Type", "text/*") - .withBody(ValuesForTest.timetableHTML))); - - EXTERNAL_SERVICE_API_MOCK.stubFor(get(urlPathMatching("/lista.html")) - .willReturn(aResponse() - .withStatus(200) - .withHeader("Content-Type", "text/*") - .withBody(ValuesForTest.listHTML))); + public void initWireMock () { + EXTERNAL_SERVICE_API_MOCK.stubFor(get(urlPathMatching("/plany/o25.html")).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/*") + .withBody(ValuesForTest.timetableHTML))); + + EXTERNAL_SERVICE_API_MOCK.stubFor(get(urlPathMatching("/lista.html")).willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/*") + .withBody(ValuesForTest.listHTML))); } @Test @@ -56,13 +49,12 @@ public void shouldHourListBePresentInCache () { //then assertAll( - () -> assertNotNull(cacheData), - () -> assertTrue(cacheData.containsKey(key)), - () -> { - var hourList = cacheData.get(key); - assertNotNull(hourList); - assertThat(hourList).isEqualTo("[\"7:30- 8:15\",\"8:15- 9:00\",\"9:15-10:00\",\"10:00-10:45\",\"11:00-11:45\",\"11:45-12:30\",\"12:45-13:30\",\"13:30-14:15\",\"14:30-15:15\",\"15:15-16:00\",\"16:15-17:00\",\"17:00-17:45\",\"18:00-18:45\",\"18:45-19:30\",\"19:45-20:30\",\"20:30-21:15\"]"); - } + () -> assertNotNull(cacheData), () -> assertTrue(cacheData.containsKey(key)), () -> { + var hourList = cacheData.get(key); + assertNotNull(hourList); + assertThat(hourList).isEqualTo( + "[\"7:30- 8:15\",\"8:15- 9:00\",\"9:15-10:00\",\"10:00-10:45\",\"11:00-11:45\",\"11:45-12:30\",\"12:45-13:30\",\"13:30-14:15\",\"14:30-15:15\",\"15:15-16:00\",\"16:15-17:00\",\"17:00-17:45\",\"18:00-18:45\",\"18:45-19:30\",\"19:45-20:30\",\"20:30-21:15\"]"); + } ); } @@ -71,16 +63,21 @@ public void shouldHourListBePresentInCache () { public void shouldReturnGeneralGroupsMap () { //given var expectedMap = Map.of( - "11K2", "plany/o8.html", - "12K1", "plany/o25.html", - "11A1", "plany/o1.html", - "12K3", "plany/o27.html", - "12K2", "plany/o26.html" + "11K2", + "plany/o8.html", + "12K1", + "plany/o25.html", + "11A1", + "plany/o1.html", + "12K3", + "plany/o27.html", + "12K2", + "plany/o26.html" ); - + //when var result = cachedService.getGeneralGroupsMap(); - + //then assertThat(result).isEqualTo(expectedMap); } @@ -94,24 +91,21 @@ public void shouldGeneralGroupMapBePresentInCache () { //when Map cacheData = cacheInspector.getAllEntries("timetables"); // get all keys saved in cache - + //then assertAll( - () -> assertNotNull(cacheData), - () -> { - assertTrue(cacheData.containsKey(key)); - var data = cacheData.get(key); - assertThat(data).isEqualTo(expectedValue); - } + () -> assertNotNull(cacheData), () -> { + assertTrue(cacheData.containsKey(key)); + var data = cacheData.get(key); + assertThat(data).isEqualTo(expectedValue); + } ); } @Test - @Disabled("Test shouldn't be random") - public void shouldReturnRandomGeneralGroupSchedule () { + public void shouldReturn12K1Schedule () { //given - List generalGroupList = service.getGeneralGroupList(); - var generalGroupName = generalGroupList.get((int) (Math.random() * generalGroupList.size())); // get random general group + var generalGroupName = "12K1"; // get random general group //when var result = cachedService.getGeneralGroupSchedule(generalGroupName); @@ -122,12 +116,9 @@ public void shouldReturnRandomGeneralGroupSchedule () { } @Test - @Disabled("Test shouldn't be random") public void shouldRandomGeneralGroupScheduleBePresentInCache () { //given - List generalGroupList = service.getGeneralGroupList(); - - String generalGroupName = generalGroupList.get((int) (Math.random() * generalGroupList.size())); // get random general group + String generalGroupName = "12K1"; // get random general group String key = "timetable_" + generalGroupName; cachedService.getGeneralGroupSchedule(generalGroupName); // call method to save data in cache diff --git a/src/test/java/org/pkwmtt/timetable/TimetableControllerTest.java b/src/test/java/org/pkwmtt/timetable/TimetableControllerTest.java index 9c5c290..2636a70 100644 --- a/src/test/java/org/pkwmtt/timetable/TimetableControllerTest.java +++ b/src/test/java/org/pkwmtt/timetable/TimetableControllerTest.java @@ -15,7 +15,6 @@ import org.springframework.http.ResponseEntity; import test.TestConfig; -import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; import java.util.regex.Matcher; @@ -53,7 +52,7 @@ public void initWireMock() { @Test public void testGetGeneralGroupScheduleFiltered_withOptionalParams () { //given - var url = String.format("http://localhost:%s/pkmwtt/api/v1/timetables/12K1?sub=K01&sub=L01&sub=P01", + var url = String.format("http://localhost:%s/pkwmtt/api/v1/timetables/12K1?sub=K01&sub=L01&sub=P01", port ); @@ -68,6 +67,7 @@ public void testGetGeneralGroupScheduleFiltered_withOptionalParams () { assertNotNull(responseBody); }, () -> { + assertNotNull(response.getBody()); var responseData = response.getBody().getData(); assertEquals(5, responseData.size()); assertEquals(12, responseData.getFirst().getOdd().size()); @@ -79,7 +79,7 @@ public void testGetGeneralGroupScheduleFiltered_withOptionalParams () { @Test public void testGetGeneralGroupScheduleFiltered_withoutParams () { //given - var url = String.format("http://localhost:%s/pkmwtt/api/v1/timetables/12K1", port); + var url = String.format("http://localhost:%s/pkwmtt/api/v1/timetables/12K1", port); //when ResponseEntity response = restTemplate.getForEntity(url, TimetableDTO.class); @@ -95,7 +95,7 @@ public void testGetGeneralGroupScheduleFiltered_withoutParams () { public void shouldReturnListOfGeneralGroups () { //given String url = String.format( - "http://localhost:%s/pkmwtt/api/v1/timetables/groups/general", + "http://localhost:%s/pkwmtt/api/v1/timetables/groups/general", port ); @@ -115,7 +115,7 @@ public void shouldReturnListOfGeneralGroups () { public void shouldReturnListOfSubgroupsForGeneralGroup () { //given String url = String.format( - "http://localhost:%s/pkmwtt/api/v1/timetables/groups/12K1", + "http://localhost:%s/pkwmtt/api/v1/timetables/groups/12K1", port ); @@ -131,7 +131,7 @@ public void shouldReturnListOfSubgroupsForGeneralGroup () { public void shouldReturn_BadRequest_SpecifiedGeneralGroupDoesntExistsException () { //given String url = String.format( - "http://localhost:%s/pkmwtt/api/v1/timetables/groups/XXXX", + "http://localhost:%s/pkwmtt/api/v1/timetables/groups/XXXX", port ); @@ -144,14 +144,13 @@ public void shouldReturn_BadRequest_SpecifiedGeneralGroupDoesntExistsException ( //then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertNotNull(response.getBody()); - assertThat(response.getBody().getTimestamp()).isBefore(LocalDateTime.now()); } @Test public void shouldReturn_BadRequest_SpecifiedSubGroupDoesntExistsException () { //given String url = String.format( - "http://localhost:%s/pkmwtt/api/v1/timetables/12K1?sub=XXX", + "http://localhost:%s/pkwmtt/api/v1/timetables/12K1?sub=XXX", port ); @@ -164,13 +163,12 @@ public void shouldReturn_BadRequest_SpecifiedSubGroupDoesntExistsException () { //then assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); assertNotNull(response.getBody()); - assertThat(response.getBody().getTimestamp()).isBefore(LocalDateTime.now()); } @Test public void shouldReturn_ListOfHours () { //given - String url = String.format("http://localhost:%s/pkmwtt/api/v1/timetables/hours", port); + String url = String.format("http://localhost:%s/pkwmtt/api/v1/timetables/hours", port); //when ResponseEntity response = restTemplate.getForEntity(url, String[].class); diff --git a/src/test/java/org/pkwmtt/timetable/TimetableServiceTest.java b/src/test/java/org/pkwmtt/timetable/TimetableServiceTest.java index c582a3f..357ccf2 100644 --- a/src/test/java/org/pkwmtt/timetable/TimetableServiceTest.java +++ b/src/test/java/org/pkwmtt/timetable/TimetableServiceTest.java @@ -13,11 +13,14 @@ import org.pkwmtt.exceptions.SpecifiedGeneralGroupDoesntExistsException; import org.pkwmtt.exceptions.SpecifiedSubGroupDoesntExistsException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import test.TestConfig; import java.util.List; import java.util.regex.Pattern; +@SpringBootTest class TimetableServiceTest extends TestConfig { @Autowired diff --git a/src/test/java/org/pkwmtt/timetable/parser/ParserServiceTest.java b/src/test/java/org/pkwmtt/timetable/parser/ParserServiceTest.java index 9dd2567..b9f856f 100644 --- a/src/test/java/org/pkwmtt/timetable/parser/ParserServiceTest.java +++ b/src/test/java/org/pkwmtt/timetable/parser/ParserServiceTest.java @@ -9,6 +9,7 @@ import org.pkwmtt.timetable.dto.TimetableDTO; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; import test.TestConfig; import java.io.IOException; diff --git a/src/test/java/test/GreenMailConfig.java b/src/test/java/test/GreenMailConfig.java new file mode 100644 index 0000000..6f40a12 --- /dev/null +++ b/src/test/java/test/GreenMailConfig.java @@ -0,0 +1,19 @@ +package test; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@TestConfiguration +public class GreenMailConfig { + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost("localhost"); + sender.setPort(3025); + sender.setUsername("test@localhost"); + sender.setPassword("test"); + return sender; + } +} diff --git a/src/test/resources/application-database.properties b/src/test/resources/application-database.properties new file mode 100644 index 0000000..7f6d0ce --- /dev/null +++ b/src/test/resources/application-database.properties @@ -0,0 +1,23 @@ +#spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false +#spring.datasource.username=sa +#spring.datasource.password= + +spring.sql.init.mode=always +spring.sql.init.schema-locations=classpath:schema.sql + +spring.jpa.show-sql=true +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.datasource.driver-class-name=org.h2.Driver +spring.jpa.properties.hibernate.temp.use_jdbc_metadata_defaults=false +#spring.jpa.hibernate.ddl-auto=create-drop +spring.datasource.hikari.initialization-fail-timeout=0 + +spring.mail.host=localhost +spring.mail.port=3025 +spring.mail.protocol=smtp +spring.mail.username=test@localhost +spring.mail.password=test +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=false + + diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql new file mode 100644 index 0000000..d598883 --- /dev/null +++ b/src/test/resources/schema.sql @@ -0,0 +1,61 @@ +DROP TABLE IF EXISTS exams_groups; +DROP TABLE IF EXISTS exams; +DROP TABLE IF EXISTS exam_type; +DROP TABLE IF EXISTS otp_codes; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS student_groups; +DROP TABLE IF EXISTS general_group; + +CREATE TABLE exam_type ( + exam_type_id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL +); + +CREATE TABLE general_group ( + general_group_id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL +); + +CREATE TABLE student_groups ( + group_id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE exams ( + exam_id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description VARCHAR(255), + exam_date TIMESTAMP NOT NULL, + exam_type_id INT NOT NULL, + CONSTRAINT fk_exams_exam_type FOREIGN KEY (exam_type_id) + REFERENCES exam_type (exam_type_id) ON DELETE CASCADE +); + +CREATE TABLE exams_groups ( + exam_group_id INT AUTO_INCREMENT PRIMARY KEY, + exam_id INT NOT NULL, + group_id INT NOT NULL, + CONSTRAINT fk_exams_groups_exam FOREIGN KEY (exam_id) + REFERENCES exams (exam_id) ON DELETE CASCADE, + CONSTRAINT fk_exams_groups_group FOREIGN KEY (group_id) + REFERENCES student_groups (group_id) ON DELETE CASCADE +); + +CREATE TABLE otp_codes ( + otp_code_id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(255) NOT NULL, + expire TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + general_group_id INT NOT NULL, + CONSTRAINT fk_otp_codes_general_group FOREIGN KEY (general_group_id) + REFERENCES general_group (general_group_id) ON DELETE CASCADE +); + +CREATE TABLE users ( + user_id INT AUTO_INCREMENT PRIMARY KEY, + general_group_id INT NOT NULL, + email VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + role VARCHAR(20) NOT NULL DEFAULT 'REPRESENTATIVE', + CONSTRAINT fk_users_general_group FOREIGN KEY (general_group_id) + REFERENCES general_group (general_group_id) ON DELETE CASCADE +); \ No newline at end of file