diff --git a/README.md b/README.md index a91a90f..ec9d9ff 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,19 @@ -# 🚀 PKWM App Backend +# ⚙ Trybik — Backend + +Trybik (Server) – timetable, exam calendar & ECTS calculator for students of Mechanical Engineering @ Cracow University of Technology --- ## 📦 Tech Stack -- **Backend Framework:** [Java Spring](https://spring.io/) -- **Language:** [Java](https://www.java.com/pl/) -- **Database:** [MySQL](https://www.mysql.com/) -- **Authentication:** [JWT](https://jwt.io/) -- **Project Manager:** [Maven](https://maven.apache.org/) -- **Containerization:** [Docker](https://www.docker.com/) +- Framework: Java Spring Boot 3.5+ +- Language: Java 21 +- Database: MySQL (H2 used for tests) +- Authentication: JWT (JSON Web Tokens) +- API Docs: Swagger / OpenAPI +- Caching: Caffeine +- Build / Project: Maven (mvn / ./mvnw) +- Containerization: Docker --- @@ -17,83 +21,123 @@ ### 1. Clone the repository -```shell -docker pull ghcr.io/pkttteam/pkwmtt-backend:latest +```bash +git clone https://github.com/TrybikDevelopers/Trybik-backend.git +cd Trybik-backend +``` + +### 2. Build the project + +If the Maven wrapper is present: + +```bash +./mvnw clean package +``` + +Or with your system Maven: + +```bash +mvn clean package +``` + +### 3. Run with Docker + +Build and run locally: + +```bash +docker build -t trybik-backend . +docker run -d --name trybik-backend -p 8080:8080 trybik-backend ``` -### 2. Run +If an official container image is published (check the Releases or container registry), you can pull and run: -```shell -docker run -d --name [image_name] -p 8080:8080 ghcr.io/pkttteam/pkwmttt-backend:[PACKAGE_NUMBER] +```bash +docker pull ghcr.io/trybikdevelopers/trybik-backend:latest +docker run -d --name trybik-backend -p 8080:8080 ghcr.io/trybikdevelopers/trybik-backend:latest ``` --- ## 📮 API Overview -The backend exposes various RESTful endpoints to manage: +This backend exposes RESTful endpoints for: -- Timetable: - - Schedule for specific general group with optional filters (K,L,P groups) - - List of available general groups (f.e. 12K1) - - List of subjects hours - - List of available KLP groups for specified general group (f.e. K01) +- Timetable management (by study group, with filters) +- Exam calendar and exam types +- ECTS calculator +- Group and subject listings +- (Other endpoints may exist — check the controller packages / OpenAPI docs) -The API follows standard REST conventions and uses JWT for authentication. Headers typically include: +## Detailed API docs +For implementation details, examples and payload shapes see the module-level API references below: + +- Timetable — Detailed docs: [TIMETABLE.MD](src/main/java/org/pkwmtt/timetable/TIMETABLE.MD) +- Exam calendar — Detailed docs: [EXAMCALENDAR.MD](src/main/java/org/pkwmtt/calendar/EXAMCALENDAR.MD) +- Moderator — Detailed docs: [MODERATOR.MD](src/main/java/org/pkwmtt/moderator/MODERATOR.MD) +- Events — Detailed docs: [EVENTS.MD](src/main/java/org/pkwmtt/calendar/EVENTS.MD) + +Authentication +- Endpoints are protected using JWT tokens. +- Example header: ``` Authorization: Bearer Content-Type: application/json ``` -> ⚠️ API documentation with Swagger may be available [here](http://localhost:8080/swagger-ui/index.html) if enabled in -> the application. +API documentation (Swagger UI / OpenAPI) is usually available at: +`http://localhost:8080/swagger-ui/index.html` or `http://localhost:8080/v3/api-docs` (if Swagger/OpenAPI is enabled in configuration). --- ## 🧪 Testing -```shell +Run unit and integration tests: + +```bash +./mvnw test +# or mvn test ``` +The project may use H2 for tests — check test configuration files for details. + --- ## 🤝 Contributing -Contributions are welcome! Follow these steps: +We welcome contributions! 1. Fork the repository -2. Create a new branch: `git checkout -b feature/your-feature-name` -3. Make your changes -4. Commit and push: `git commit -m "feat: add new feature" && git push` -5. Submit a pull request 🚀 +2. Create a new branch: `git checkout -b feature/your-feature` +3. Make your changes and add tests where appropriate +4. Commit and push: + ```bash + git commit -m "feat: short description" + git push origin feature/your-feature + ``` +5. Open a pull request against the main branch and describe your changes + +Please follow the existing code style and include tests for new behavior when possible. --- ## 📄 License -This project is licensed under the **MIT License**. See the [LICENSE](./LICENSE) file for details. +This project is licensed under the MIT License. See [LICENSE](./LICENSE) for details. --- -## 💬 Contact +## 💬 Contact / Support -For questions, suggestions, or collaboration: +- Issues: https://github.com/TrybikDevelopers/Trybik-backend/issues +- Organization: https://github.com/TrybikDevelopers +- Email: support@trybik.app -- GitHub Issues: [Submit here](https://github.com/PKWMApp/PKWMTT-backend/issues) -- Team: [@PKWMApp](https://github.com/PKWMApp - ) +If you have questions about API usage or want to report bugs, please open an issue with reproduction steps and relevant logs. --- ## 🌐 Related Projects ---- - -## 📸 Screenshots (Optional) - ---- - - - +- Frontend / mobile apps — check the organization repositories for matching frontend projects. diff --git a/docker-compose.yml b/docker-compose.yml index 5897749..6f352bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: restart: always volumes: - ./uploads:/app/uploads + - ./logs:/app/logs environment: SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} db: diff --git a/init.sql b/init.sql index c9bee5e..9459207 100644 --- a/init.sql +++ b/init.sql @@ -3,8 +3,8 @@ -- https://www.phpmyadmin.net/ -- -- Host: db --- Generation Time: Wrz 25, 2025 at 06:13 PM --- Wersja serwera: 9.3.0 +-- Generation Time: Lis 16, 2025 at 01:29 PM +-- Wersja serwera: 9.4.0 -- Wersja PHP: 8.2.27 SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; @@ -30,21 +30,19 @@ USE `pktt`; -- DROP TABLE IF EXISTS `admin_keys`; -CREATE TABLE IF NOT EXISTS `admin_keys` ( - `key_id` int NOT NULL AUTO_INCREMENT, - `value` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - `description` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - PRIMARY KEY (`key_id`), - UNIQUE KEY `unique_value` (`value`) -) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +CREATE TABLE `admin_keys` ( + `key_id` int NOT NULL, + `value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, + `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL +) ENGINE=InnoDB 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'), -(4, '2868b02b-a5dd-4386-a723-e450e5f54418', 'desc'); +(6, '$2a$10$AF/3/7aVlFk4Ypqk7Te/uuLGhtXPmrkESNn3.kzcCoRDW8FRGBRu2', 'mikolaj'), +(8, '$2a$10$EQU7/.muQM/e1aZtw.FVK.UAk/4SsRGeUIzLSsplrPi/JnAYHF8V2', 'patryk'); -- -------------------------------------------------------- @@ -53,51 +51,97 @@ INSERT INTO `admin_keys` (`key_id`, `value`, `description`) VALUES -- DROP TABLE IF EXISTS `api_keys`; -CREATE TABLE IF NOT EXISTS `api_keys` ( - `key_id` int NOT NULL AUTO_INCREMENT, - `value` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - `description` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - PRIMARY KEY (`key_id`), - UNIQUE KEY `unique_value` (`value`) -) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +CREATE TABLE `api_keys` ( + `key_id` int NOT NULL, + `value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, + `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL +) ENGINE=InnoDB 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'); +(4, '$2a$10$uUvJtEEewxJsdUvI5kE0Iuvcv8MeixlfMML.Jx0XicXKT2AtMHP32', 'mobile app'), +(5, '$2a$10$VuoisZPoCWNBXtdQEEGQXO.T4SK1mQGXeA6JSM1KW4MUQ.JSuy7C2', 'web app'); -- -------------------------------------------------------- -- --- Struktura tabeli dla tabeli `exams` +-- Struktura tabeli dla tabeli `bug_reports` -- -DROP TABLE IF EXISTS `exams`; -CREATE TABLE IF NOT EXISTS `exams` ( - `exam_id` int NOT NULL AUTO_INCREMENT, - `title` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - `description` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL, - `exam_date` datetime NOT NULL, - `exam_type_id` int NOT NULL, - PRIMARY KEY (`exam_id`), - KEY `exam_type_id_idx` (`exam_type_id`) -) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +DROP TABLE IF EXISTS `bug_reports`; +CREATE TABLE `bug_reports` ( + `report_id` int NOT NULL, + `user_groups` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `description` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `issued_at` timestamp NULL DEFAULT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- Struktura tabeli dla tabeli `events` +-- + +DROP TABLE IF EXISTS `events`; +CREATE TABLE `events` ( + `event_id` int NOT NULL, + `title` varchar(255) NOT NULL, + `description` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `type` int NOT NULL, + `start_date` datetime NOT NULL, + `end_date` datetime NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- -------------------------------------------------------- -- --- Zrzut danych tabeli `exams` +-- Struktura tabeli dla tabeli `events_superior_group` -- -INSERT INTO `exams` (`exam_id`, `title`, `description`, `exam_date`, `exam_type_id`) VALUES -(2, 'Egzamin końcowy z programowania', 'Egzamin pisemny i praktyczny', '2025-01-20 09:00:00', 2), -(3, 'Projekt z baz danych', 'Oddanie projektu grupowego', '2025-06-15 23:59:00', 3), -(4, 'Kolokwium z fizyki', 'Druga część materiału: mechanika', '2025-11-05 12:00:00', 1), -(5, 'Egzamin końcowy z ekonomii', 'Egzamin pisemny testowy', '2025-02-10 08:30:00', 2), -(6, 'Projekt z systemów operacyjnych', 'Prezentacja projektu semestralnego', '2025-06-25 14:00:00', 3), -(7, 'test authorities', 'do usuniecia', '2027-09-01 09:00:00', 3), -(8, 'test authorities', 'do usuniecia', '2027-09-01 09:00:00', 3), -(9, 'test authorities', 'do usunieciaaaaa', '2027-09-01 09:00:00', 3); +DROP TABLE IF EXISTS `events_superior_group`; +CREATE TABLE `events_superior_group` ( + `row_id` int NOT NULL, + `event_id` int NOT NULL, + `superior_group_id` int NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- -------------------------------------------------------- + +-- +-- Struktura tabeli dla tabeli `event_types` +-- + +DROP TABLE IF EXISTS `event_types`; +CREATE TABLE `event_types` ( + `event_type_id` int NOT NULL, + `name` varchar(100) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- +-- Zrzut danych tabeli `event_types` +-- + +INSERT INTO `event_types` (`event_type_id`, `name`) VALUES +(2, 'Academic calendar'), +(3, 'Day off'); + +-- -------------------------------------------------------- + +-- +-- Struktura tabeli dla tabeli `exams` +-- + +DROP TABLE IF EXISTS `exams`; +CREATE TABLE `exams` ( + `exam_id` int NOT NULL, + `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, + `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL, + `exam_date` datetime NOT NULL, + `exam_type_id` int NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -- -------------------------------------------------------- @@ -106,55 +150,29 @@ INSERT INTO `exams` (`exam_id`, `title`, `description`, `exam_date`, `exam_type_ -- DROP TABLE IF EXISTS `exams_groups`; -CREATE TABLE IF NOT EXISTS `exams_groups` ( - `exam_group_id` int NOT NULL AUTO_INCREMENT, +CREATE TABLE `exams_groups` ( + `exam_group_id` int NOT NULL, `exam_id` int NOT NULL, - `group_id` int NOT NULL, - PRIMARY KEY (`exam_group_id`), - KEY `exam_id_idx` (`exam_id`), - KEY `group_id_idx` (`group_id`) -) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; - --- --- Zrzut danych tabeli `exams_groups` --- - -INSERT INTO `exams_groups` (`exam_group_id`, `exam_id`, `group_id`) VALUES -(9, 2, 12), -(10, 2, 13), -(11, 2, 14), -(12, 3, 15), -(13, 3, 16), -(14, 3, 17), -(15, 4, 9), -(16, 4, 10), -(17, 5, 12), -(18, 5, 13), -(19, 6, 15), -(20, 6, 16), -(21, 7, 21), -(22, 7, 22), -(23, 8, 9), -(24, 9, 9); + `group_id` int NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -- -------------------------------------------------------- -- --- Struktura tabeli dla tabeli `exam_type` +-- Struktura tabeli dla tabeli `exam_types` -- -DROP TABLE IF EXISTS `exam_type`; -CREATE TABLE IF NOT EXISTS `exam_type` ( - `exam_type_id` int NOT NULL AUTO_INCREMENT, - `name` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - PRIMARY KEY (`exam_type_id`) -) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +DROP TABLE IF EXISTS `exam_types`; +CREATE TABLE `exam_types` ( + `exam_type_id` int NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -- --- Zrzut danych tabeli `exam_type` +-- Zrzut danych tabeli `exam_types` -- -INSERT INTO `exam_type` (`exam_type_id`, `name`) VALUES +INSERT INTO `exam_types` (`exam_type_id`, `name`) VALUES (1, 'Kolokwium'), (2, 'Egzamin końcowy'), (3, 'Projekt'); @@ -162,76 +180,83 @@ INSERT INTO `exam_type` (`exam_type_id`, `name`) VALUES -- -------------------------------------------------------- -- --- Struktura tabeli dla tabeli `general_group` +-- Struktura tabeli dla tabeli `moderators` -- -DROP TABLE IF EXISTS `general_group`; -CREATE TABLE IF NOT EXISTS `general_group` ( - `general_group_id` int NOT NULL AUTO_INCREMENT, - `name` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - PRIMARY KEY (`general_group_id`) -) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +DROP TABLE IF EXISTS `moderators`; +CREATE TABLE `moderators` ( + `moderator_id` varchar(36) NOT NULL, + `password` varchar(255) NOT NULL, + `role` varchar(50) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- --- Zrzut danych tabeli `general_group` +-- Zrzut danych tabeli `moderators` -- -INSERT INTO `general_group` (`general_group_id`, `name`) VALUES -(17, '11A'), -(18, '12E'), -(19, '13K'), -(20, '14M'), -(21, '12K'), -(22, '11K'); +INSERT INTO `moderators` (`moderator_id`, `password`, `role`) VALUES +('20caa1cc-4897-471d-a7cf-aa763d569b2e', '$2a$10$DGguCtLbZXE1gj6P2uns8OLNmB5s3ok50RZTBNMkVhgpLreU5/1um', 'MODERATOR'); -- -------------------------------------------------------- -- --- Struktura tabeli dla tabeli `moderators` +-- Struktura tabeli dla tabeli `moderator_refresh_tokens` -- -DROP TABLE IF EXISTS `moderators`; -CREATE TABLE IF NOT EXISTS `moderators` ( - `moderator_id` binary(16) NOT NULL, - `password` varchar(255) NOT NULL, - `role` varchar(50) NOT NULL, - PRIMARY KEY (`moderator_id`) +DROP TABLE IF EXISTS `moderator_refresh_tokens`; +CREATE TABLE `moderator_refresh_tokens` ( + `token_id` bigint NOT NULL, + `token` char(64) NOT NULL, + `moderator_id` varchar(36) NOT NULL, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `expires` datetime NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; +-- -------------------------------------------------------- + -- --- Zrzut danych tabeli `moderators` +-- Struktura tabeli dla tabeli `refresh_token` -- -INSERT INTO `moderators` (`moderator_id`, `password`, `role`) VALUES -(0x10b4cd4f840445ba9fff930fda8229c7, '$2a$10$zjcQISWSqPpMWQv99XWneOaHWiqTRhiXUJq5FT8iXbET.3hZfO0GO', 'MODERATOR'), -(0x561e6e496eab4e9e965ac6c8ffe24293, '$2a$10$puIitW1sPdjyqCs2nbpco.wRAcGOpuWiOj6iQ0siFKOBaKmIS9ghK', 'MODERATOR'), -(0x9e39a89631924bd6a38b0ee6e56be221, '$2a$10$e6H5n6xu4NymerBHqvO42OhUVg3aOHPpCPo0.TSDH1b/graC5FomC', 'MODERATOR'), -(0xd45dd77e68ac45908a12340b35d04b6c, '$2a$10$k6aoa0OU8RKbCA4WHu4yDuMOZFmxP2zeX7Cjw3GmLVml2dDm6QGEG', 'MODERATOR'); +DROP TABLE IF EXISTS `refresh_token`; +CREATE TABLE `refresh_token` ( + `token_id` bigint NOT NULL, + `token` char(64) NOT NULL, + `user_id` int NOT NULL, + `enabled` tinyint(1) NOT NULL DEFAULT '1', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `expires_at` datetime NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; -- -------------------------------------------------------- -- --- Struktura tabeli dla tabeli `otp_codes` +-- Struktura tabeli dla tabeli `representatives` -- -DROP TABLE IF EXISTS `otp_codes`; -CREATE TABLE IF NOT EXISTS `otp_codes` ( - `otp_code_id` int NOT NULL AUTO_INCREMENT, - `code` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - `expire` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `general_group_id` int 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; +DROP TABLE IF EXISTS `representatives`; +CREATE TABLE `representatives` ( + `representative_id` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `superior_group_id` int NOT NULL, + `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT '1' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- -------------------------------------------------------- -- --- Zrzut danych tabeli `otp_codes` +-- Struktura tabeli dla tabeli `student_codes` -- -INSERT INTO `otp_codes` (`otp_code_id`, `code`, `expire`, `general_group_id`) VALUES -(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); +DROP TABLE IF EXISTS `student_codes`; +CREATE TABLE `student_codes` ( + `student_code_id` int NOT NULL, + `code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, + `expire` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `superior_group_id` int NOT NULL, + `usage_count` int NOT NULL, + `usage_limit` int NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -- -------------------------------------------------------- @@ -240,72 +265,298 @@ INSERT INTO `otp_codes` (`otp_code_id`, `code`, `expire`, `general_group_id`) VA -- DROP TABLE IF EXISTS `student_groups`; -CREATE TABLE IF NOT EXISTS `student_groups` ( - `group_id` int NOT NULL AUTO_INCREMENT, - `name` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - PRIMARY KEY (`group_id`), - UNIQUE KEY `name` (`name`) -) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; - --- --- Zrzut danych tabeli `student_groups` --- - -INSERT INTO `student_groups` (`group_id`, `name`) VALUES -(22, '11A'), -(9, '11A1'), -(10, '11A2'), -(12, '12E1'), -(13, '12E2'), -(14, '12E3'), -(15, '13K1'), -(16, '13K2'), -(17, '13K3'), -(18, '14M1'), -(21, 'P01'); +CREATE TABLE `student_groups` ( + `group_id` int NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -- -------------------------------------------------------- -- --- Struktura tabeli dla tabeli `users` +-- Struktura tabeli dla tabeli `superior_groups` +-- + +DROP TABLE IF EXISTS `superior_groups`; +CREATE TABLE `superior_groups` ( + `superior_group_id` int NOT NULL, + `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- -------------------------------------------------------- + +-- +-- Struktura tabeli dla tabeli `user_refresh_tokens` +-- + +DROP TABLE IF EXISTS `user_refresh_tokens`; +CREATE TABLE `user_refresh_tokens` ( + `token_id` bigint NOT NULL, + `token` char(64) NOT NULL, + `representative_id` varchar(36) NOT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `expires_at` datetime NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- -------------------------------------------------------- + +-- +-- Struktura tabeli dla tabeli `utils_kv` +-- + +DROP TABLE IF EXISTS `utils_kv`; +CREATE TABLE `utils_kv` ( + `id` int NOT NULL, + `property_key` varchar(191) NOT NULL, + `property_value` varchar(250) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL, + `value_type` varchar(20) NOT NULL DEFAULT 'string', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +-- +-- Zrzut danych tabeli `utils_kv` +-- + +INSERT INTO `utils_kv` (`id`, `property_key`, `property_value`, `value_type`, `updated_at`) VALUES +(16, 'endOfSemester', '2026-02-28', 'date', '2025-10-20 18:26:50'); + +-- +-- Indeksy dla zrzutów tabel +-- + +-- +-- Indeksy dla tabeli `admin_keys` +-- +ALTER TABLE `admin_keys` + ADD PRIMARY KEY (`key_id`), + ADD UNIQUE KEY `unique_value` (`value`); + +-- +-- Indeksy dla tabeli `api_keys` +-- +ALTER TABLE `api_keys` + ADD PRIMARY KEY (`key_id`), + ADD UNIQUE KEY `unique_value` (`value`); + +-- +-- Indeksy dla tabeli `bug_reports` +-- +ALTER TABLE `bug_reports` + ADD PRIMARY KEY (`report_id`); + +-- +-- Indeksy dla tabeli `events` +-- +ALTER TABLE `events` + ADD PRIMARY KEY (`event_id`), + ADD KEY `events_types` (`type`) USING BTREE; + +-- +-- Indeksy dla tabeli `events_superior_group` +-- +ALTER TABLE `events_superior_group` + ADD PRIMARY KEY (`row_id`), + ADD KEY `index_superior_group` (`superior_group_id`) USING BTREE, + ADD KEY `index_event` (`event_id`); + +-- +-- Indeksy dla tabeli `event_types` +-- +ALTER TABLE `event_types` + ADD PRIMARY KEY (`event_type_id`); + +-- +-- 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_types` -- +ALTER TABLE `exam_types` + ADD PRIMARY KEY (`exam_type_id`); -DROP TABLE IF EXISTS `users`; -CREATE TABLE IF NOT EXISTS `users` ( - `user_id` int NOT NULL AUTO_INCREMENT, - `general_group_id` int NOT NULL, - `email` varchar(255) COLLATE utf8mb4_general_ci NOT NULL, - `is_active` tinyint(1) NOT NULL DEFAULT '1', - `role` enum('ADMIN','REPRESENTATIVE') COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'REPRESENTATIVE', - PRIMARY KEY (`user_id`), - KEY `general_group_id_idx` (`general_group_id`) -) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +-- +-- Indeksy dla tabeli `moderators` +-- +ALTER TABLE `moderators` + ADD PRIMARY KEY (`moderator_id`); + +-- +-- Indeksy dla tabeli `moderator_refresh_tokens` +-- +ALTER TABLE `moderator_refresh_tokens` + ADD PRIMARY KEY (`token_id`), + ADD UNIQUE KEY `token` (`token`), + ADD KEY `idx_moderator_id` (`moderator_id`); + +-- +-- Indeksy dla tabeli `representatives` +-- +ALTER TABLE `representatives` + ADD PRIMARY KEY (`representative_id`), + ADD KEY `general_group_id_idx` (`superior_group_id`); -- --- Zrzut danych tabeli `users` +-- Indeksy dla tabeli `student_codes` -- +ALTER TABLE `student_codes` + ADD PRIMARY KEY (`student_code_id`), + ADD KEY `general_group_id_idx` (`superior_group_id`); -INSERT INTO `users` (`user_id`, `general_group_id`, `email`, `is_active`, `role`) VALUES -(1, 17, 'user11a@example.com', 1, 'REPRESENTATIVE'), -(2, 18, 'user12e@example.com', 1, 'REPRESENTATIVE'), -(3, 19, 'user13k@example.com', 1, 'REPRESENTATIVE'), -(4, 20, 'user14m@example.com', 1, 'ADMIN'), -(5, 21, 'email@ex.com', 0, 'REPRESENTATIVE'), -(6, 21, 'email@ex.com', 0, 'REPRESENTATIVE'), -(7, 21, 'email@ex.com', 0, 'REPRESENTATIVE'), -(8, 21, 'email@ex.com', 0, 'REPRESENTATIVE'), -(9, 21, 'email@ex.com', 0, 'REPRESENTATIVE'), -(10, 22, 'email@ex.com', 0, 'REPRESENTATIVE'); +-- +-- Indeksy dla tabeli `student_groups` +-- +ALTER TABLE `student_groups` + ADD PRIMARY KEY (`group_id`), + ADD UNIQUE KEY `name` (`name`); + +-- +-- Indeksy dla tabeli `superior_groups` +-- +ALTER TABLE `superior_groups` + ADD PRIMARY KEY (`superior_group_id`); + +-- +-- Indeksy dla tabeli `user_refresh_tokens` +-- +ALTER TABLE `user_refresh_tokens` + ADD PRIMARY KEY (`token_id`), + ADD UNIQUE KEY `token` (`token`), + ADD KEY `idx_representative_id` (`representative_id`); + +-- +-- Indeksy dla tabeli `utils_kv` +-- +ALTER TABLE `utils_kv` + ADD PRIMARY KEY (`id`), + ADD UNIQUE KEY `property_key` (`property_key`); + +-- +-- AUTO_INCREMENT dla zrzuconych tabel +-- + +-- +-- AUTO_INCREMENT dla tabeli `admin_keys` +-- +ALTER TABLE `admin_keys` + MODIFY `key_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=9; + +-- +-- AUTO_INCREMENT dla tabeli `api_keys` +-- +ALTER TABLE `api_keys` + MODIFY `key_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6; + +-- +-- AUTO_INCREMENT dla tabeli `bug_reports` +-- +ALTER TABLE `bug_reports` + MODIFY `report_id` int NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT dla tabeli `events` +-- +ALTER TABLE `events` + MODIFY `event_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; + +-- +-- AUTO_INCREMENT dla tabeli `events_superior_group` +-- +ALTER TABLE `events_superior_group` + MODIFY `row_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=3; + +-- +-- AUTO_INCREMENT dla tabeli `event_types` +-- +ALTER TABLE `event_types` + MODIFY `event_type_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; + +-- +-- AUTO_INCREMENT dla tabeli `exams` +-- +ALTER TABLE `exams` + MODIFY `exam_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=10; + +-- +-- AUTO_INCREMENT dla tabeli `exams_groups` +-- +ALTER TABLE `exams_groups` + MODIFY `exam_group_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=25; + +-- +-- AUTO_INCREMENT dla tabeli `exam_types` +-- +ALTER TABLE `exam_types` + MODIFY `exam_type_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=4; + +-- +-- AUTO_INCREMENT dla tabeli `moderator_refresh_tokens` +-- +ALTER TABLE `moderator_refresh_tokens` + MODIFY `token_id` bigint NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=16; + +-- +-- AUTO_INCREMENT dla tabeli `student_codes` +-- +ALTER TABLE `student_codes` + MODIFY `student_code_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=10; + +-- +-- AUTO_INCREMENT dla tabeli `student_groups` +-- +ALTER TABLE `student_groups` + MODIFY `group_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=23; + +-- +-- AUTO_INCREMENT dla tabeli `superior_groups` +-- +ALTER TABLE `superior_groups` + MODIFY `superior_group_id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=24; + +-- +-- AUTO_INCREMENT dla tabeli `user_refresh_tokens` +-- +ALTER TABLE `user_refresh_tokens` + MODIFY `token_id` bigint NOT NULL AUTO_INCREMENT; + +-- +-- AUTO_INCREMENT dla tabeli `utils_kv` +-- +ALTER TABLE `utils_kv` + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=17; -- -- Ograniczenia dla zrzutów tabel -- +-- +-- Ograniczenia dla tabeli `events` +-- +ALTER TABLE `events` + ADD CONSTRAINT `events_ibfk_1` FOREIGN KEY (`type`) REFERENCES `event_types` (`event_type_id`); + +-- +-- Ograniczenia dla tabeli `events_superior_group` +-- +ALTER TABLE `events_superior_group` + ADD CONSTRAINT `events_superior_group_ibfk_1` FOREIGN KEY (`superior_group_id`) REFERENCES `superior_groups` (`superior_group_id`) ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT `events_superior_group_ibfk_2` FOREIGN KEY (`event_id`) REFERENCES `events` (`event_id`) ON DELETE CASCADE ON UPDATE CASCADE; + -- -- Ograniczenia dla tabeli `exams` -- ALTER TABLE `exams` - ADD CONSTRAINT `exams_ibfk_1` FOREIGN KEY (`exam_type_id`) REFERENCES `exam_type` (`exam_type_id`) ON DELETE CASCADE; + ADD CONSTRAINT `exams_ibfk_1` FOREIGN KEY (`exam_type_id`) REFERENCES `exam_types` (`exam_type_id`) ON DELETE CASCADE; -- -- Ograniczenia dla tabeli `exams_groups` @@ -315,16 +566,28 @@ ALTER TABLE `exams_groups` ADD CONSTRAINT `exams_groups_ibfk_2` FOREIGN KEY (`group_id`) REFERENCES `student_groups` (`group_id`) ON DELETE CASCADE; -- --- Ograniczenia dla tabeli `otp_codes` +-- Ograniczenia dla tabeli `moderator_refresh_tokens` +-- +ALTER TABLE `moderator_refresh_tokens` + ADD CONSTRAINT `moderator_refresh_tokens_ibfk_1` FOREIGN KEY (`moderator_id`) REFERENCES `moderators` (`moderator_id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- +-- Ograniczenia dla tabeli `representatives` +-- +ALTER TABLE `representatives` + ADD CONSTRAINT `representatives_ibfk_1` FOREIGN KEY (`superior_group_id`) REFERENCES `superior_groups` (`superior_group_id`) ON DELETE CASCADE; + +-- +-- Ograniczenia dla tabeli `student_codes` -- -ALTER TABLE `otp_codes` - ADD CONSTRAINT `otp_codes_ibfk_1` FOREIGN KEY (`general_group_id`) REFERENCES `general_group` (`general_group_id`) ON DELETE CASCADE; +ALTER TABLE `student_codes` + ADD CONSTRAINT `student_codes_ibfk_1` FOREIGN KEY (`superior_group_id`) REFERENCES `superior_groups` (`superior_group_id`) ON DELETE CASCADE; -- --- Ograniczenia dla tabeli `users` +-- Ograniczenia dla tabeli `user_refresh_tokens` -- -ALTER TABLE `users` - ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`general_group_id`) REFERENCES `general_group` (`general_group_id`) ON DELETE CASCADE; +ALTER TABLE `user_refresh_tokens` + ADD CONSTRAINT `user_refresh_tokens_ibfk_1` FOREIGN KEY (`representative_id`) REFERENCES `representatives` (`representative_id`) ON DELETE CASCADE ON UPDATE CASCADE; COMMIT; /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; diff --git a/pom.xml b/pom.xml index 7347e3f..ea0f338 100644 --- a/pom.xml +++ b/pom.xml @@ -138,12 +138,12 @@ spring-boot-starter-cache - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - 2.8.12 - + + + + + + diff --git a/src/main/java/org/pkwmtt/PkwmttBackendApplication.java b/src/main/java/org/pkwmtt/PkwmttBackendApplication.java index 7e345aa..f6583e5 100644 --- a/src/main/java/org/pkwmtt/PkwmttBackendApplication.java +++ b/src/main/java/org/pkwmtt/PkwmttBackendApplication.java @@ -3,12 +3,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling @Slf4j public class PkwmttBackendApplication { - - public static void main(String[] args) { + public static void main (String[] args) { SpringApplication.run(PkwmttBackendApplication.class, args); } } diff --git a/src/main/java/org/pkwmtt/security/admin/AdminController.java b/src/main/java/org/pkwmtt/admin/AdminController.java similarity index 51% rename from src/main/java/org/pkwmtt/security/admin/AdminController.java rename to src/main/java/org/pkwmtt/admin/AdminController.java index 42d94cd..3d3b906 100644 --- a/src/main/java/org/pkwmtt/security/admin/AdminController.java +++ b/src/main/java/org/pkwmtt/admin/AdminController.java @@ -1,11 +1,14 @@ -package org.pkwmtt.security.admin; +package org.pkwmtt.admin; import lombok.RequiredArgsConstructor; -import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.calendar.exams.enums.Role; +import org.pkwmtt.reports.BugReportsService; +import org.pkwmtt.reports.dto.BugReportDTO; import org.pkwmtt.security.apiKey.ApiKeyService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.Map; @RestController @@ -14,14 +17,12 @@ public class AdminController { private final ApiKeyService service; private final AdminService adminService; + private final BugReportsService bugReportsService; - @GetMapping("") - public String adminPanel () { - return "ADMIN"; - } @PostMapping("/api/keys/generate") - public String generateApiKey (@RequestParam(name = "d") String description, @RequestParam(name = "r") Role role) { + public String generateApiKey (@RequestParam(name = "d") String description, + @RequestParam(name = "r") Role role) { return service.generateApiKey(description, role); } @@ -29,11 +30,21 @@ public String generateApiKey (@RequestParam(name = "d") String description, @Req public Map getMapOfPublicApiKeys () { return service.getMapOfPublicApiKeys(); } - + @PostMapping("/add-moderator") - public ResponseEntity addModerator(){ + public ResponseEntity addModerator () { return ResponseEntity.ok(adminService.addModerator()); } + @GetMapping("/bugs/reports") + public ResponseEntity> getBugReports () { + return ResponseEntity.ok(bugReportsService.getAllBugReports()); + } + + @DeleteMapping("/bugs/reports") + public ResponseEntity deleteBugReport (@RequestParam(name = "id") int id) { + bugReportsService.removeBugReport(id); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/org/pkwmtt/security/admin/AdminRequestInterceptor.java b/src/main/java/org/pkwmtt/admin/AdminRequestInterceptor.java similarity index 58% rename from src/main/java/org/pkwmtt/security/admin/AdminRequestInterceptor.java rename to src/main/java/org/pkwmtt/admin/AdminRequestInterceptor.java index 5683b46..a724066 100644 --- a/src/main/java/org/pkwmtt/security/admin/AdminRequestInterceptor.java +++ b/src/main/java/org/pkwmtt/admin/AdminRequestInterceptor.java @@ -1,39 +1,46 @@ -package org.pkwmtt.security.admin; +package org.pkwmtt.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.calendar.exams.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; +import static java.util.Objects.isNull; + @RequiredArgsConstructor @Component public class AdminRequestInterceptor implements HandlerInterceptor { + + private static final String X_ADMIN_KEY_HEADER = "X-ADMIN-KEY"; + private final ApiKeyService apiKeyService; @Override - public boolean preHandle (@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) { - String headerName = "X-ADMIN-KEY"; + public boolean preHandle (@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler) throws MissingHeaderException { try { - String providedApiKey = request.getHeader(headerName); + String providedApiKey = request.getHeader(X_ADMIN_KEY_HEADER); - if (providedApiKey == null || providedApiKey.isBlank()) { - throw new MissingHeaderException(headerName); + if (isNull(providedApiKey) || providedApiKey.isBlank()) { + throw new MissingHeaderException(X_ADMIN_KEY_HEADER); } apiKeyService.validateApiKey(providedApiKey, Role.ADMIN); - } catch (IncorrectApiKeyValue | MissingHeaderException e) { + } catch (MissingHeaderException e) { + throw new MissingHeaderException(X_ADMIN_KEY_HEADER); + } catch (IncorrectApiKeyValue e) { throw new IncorrectApiKeyValue(); } catch (Exception e) { - throw new InternalException("Internal server error with validating API key."); + throw new InternalException("Internal server error while validating API key."); } - return true; } diff --git a/src/main/java/org/pkwmtt/security/admin/AdminService.java b/src/main/java/org/pkwmtt/admin/AdminService.java similarity index 86% rename from src/main/java/org/pkwmtt/security/admin/AdminService.java rename to src/main/java/org/pkwmtt/admin/AdminService.java index dfb6526..11deade 100644 --- a/src/main/java/org/pkwmtt/security/admin/AdminService.java +++ b/src/main/java/org/pkwmtt/admin/AdminService.java @@ -1,9 +1,9 @@ -package org.pkwmtt.security.admin; +package org.pkwmtt.admin; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; -import org.pkwmtt.security.moderator.Moderator; -import org.pkwmtt.security.moderator.ModeratorRepository; +import org.pkwmtt.moderator.entities.Moderator; +import org.pkwmtt.moderator.repositories.ModeratorRepository; import org.pkwmtt.security.password.PasswordGenerator; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; diff --git a/src/main/java/org/pkwmtt/security/admin/entity/AdminKey.java b/src/main/java/org/pkwmtt/admin/entity/AdminKey.java similarity index 90% rename from src/main/java/org/pkwmtt/security/admin/entity/AdminKey.java rename to src/main/java/org/pkwmtt/admin/entity/AdminKey.java index 3d6031b..ae75464 100644 --- a/src/main/java/org/pkwmtt/security/admin/entity/AdminKey.java +++ b/src/main/java/org/pkwmtt/admin/entity/AdminKey.java @@ -1,4 +1,4 @@ -package org.pkwmtt.security.admin.entity; +package org.pkwmtt.admin.entity; import jakarta.persistence.Entity; import jakarta.persistence.Table; diff --git a/src/main/java/org/pkwmtt/security/admin/repository/AdminKeyRepository.java b/src/main/java/org/pkwmtt/admin/repository/AdminKeyRepository.java similarity index 66% rename from src/main/java/org/pkwmtt/security/admin/repository/AdminKeyRepository.java rename to src/main/java/org/pkwmtt/admin/repository/AdminKeyRepository.java index a6d8744..3d71b1d 100644 --- a/src/main/java/org/pkwmtt/security/admin/repository/AdminKeyRepository.java +++ b/src/main/java/org/pkwmtt/admin/repository/AdminKeyRepository.java @@ -1,6 +1,6 @@ -package org.pkwmtt.security.admin.repository; +package org.pkwmtt.admin.repository; -import org.pkwmtt.security.admin.entity.AdminKey; +import org.pkwmtt.admin.entity.AdminKey; import org.springframework.data.jpa.repository.JpaRepository; public interface AdminKeyRepository extends JpaRepository { diff --git a/src/main/java/org/pkwmtt/cache/CacheConfig.java b/src/main/java/org/pkwmtt/cache/CacheConfig.java index 806a692..76f59e9 100644 --- a/src/main/java/org/pkwmtt/cache/CacheConfig.java +++ b/src/main/java/org/pkwmtt/cache/CacheConfig.java @@ -1,30 +1,37 @@ package org.pkwmtt.cache; import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.List; import java.util.concurrent.TimeUnit; +@Slf4j @Configuration @EnableCaching public class CacheConfig { - + @Bean - public Caffeine caffeineConfig() { + public Caffeine caffeineConfig () { return Caffeine.newBuilder() - .expireAfterWrite(12, TimeUnit.HOURS) - .recordStats(); + .expireAfterWrite(5, TimeUnit.DAYS) + .recordStats(); } - + @Bean - public CacheManager cacheManager(Caffeine caffeine) { - CaffeineCacheManager cacheManager = new CaffeineCacheManager("timetables"); + public CacheManager cacheManager (Caffeine caffeine) { + log.info("Initializing Caffeine Cache Manager with 5-days expiration"); + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + // register caches used across the application so they are created upfront + cacheManager.setCacheNames(List.of("timetables", "utils")); cacheManager.setCaffeine(caffeine); + log.info("Caffeine Cache Manager initialized successfully"); return cacheManager; } - + } diff --git a/src/main/java/org/pkwmtt/cache/ScheduledCache.java b/src/main/java/org/pkwmtt/cache/ScheduledCache.java new file mode 100644 index 0000000..944ccee --- /dev/null +++ b/src/main/java/org/pkwmtt/cache/ScheduledCache.java @@ -0,0 +1,65 @@ +package org.pkwmtt.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.pkwmtt.timetable.TimetableCacheService; +import org.springframework.cache.CacheManager; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Scheduled task that evicts configured caches every day at midnight. + * By default this uses the server's local timezone; if you need a specific timezone + * set the "zone" attribute on the @Scheduled annotation (for example zone = "UTC"). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ScheduledCache { + private final CacheManager cacheManager; + private final TimetableCacheService cacheService; + + @Scheduled(cron = "0 0 1 * * *", zone = "Europe/Warsaw") + public void refreshCachesAtOneAM () throws JsonProcessingException { + log.info("Scheduled cache refresh at 01:00 - attempting prepopulation before clearing caches"); + + var generalGroups = cacheService.getGeneralGroupsMap().keySet(); + var toRepopulate = new java.util.ArrayList(); + + // Pre-check: ensure all groups can be fetched successfully before clearing caches. + for (var generalGroup : generalGroups) { + try { + cacheService.getGeneralGroupSchedule(generalGroup); + toRepopulate.add(generalGroup); + log.debug("Fetched timetable for general group '{}' (pre-check)", generalGroup); + } catch (Exception ex) { + log.warn( + "Prepopulation check failed for general group '{}', aborting cache refresh", generalGroup, ex); + return; + } + } + + // All pre-checks succeeded -> clear caches. + for (String name : cacheManager.getCacheNames()) { + var cache = cacheManager.getCache(name); + if (cache != null) { + cache.clear(); + log.debug("Cleared cache '{}'", name); + } + } + + // Repopulate caches. + for (var generalGroup : toRepopulate) { + try { + cacheService.getGeneralGroupSchedule(generalGroup); + log.debug("Prepopulated timetable cache for general group '{}'", generalGroup); + } catch (Exception ex) { + log.warn("Failed to prepopulate timetable cache for general group '{}'", generalGroup, ex); + } + } + log.info("Scheduled cache refresh at 01:00 completed"); + } + +} + diff --git a/src/main/java/org/pkwmtt/calendar/EVENTS.MD b/src/main/java/org/pkwmtt/calendar/EVENTS.MD new file mode 100644 index 0000000..a2e80da --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/EVENTS.MD @@ -0,0 +1,106 @@ +# Events — API Reference + +This document explains the REST endpoints exposed by `EventsController`. + +Base URL: + +http://localhost:8080/pkwmtt/api/v1/events + +Summary / quick checklist +- Use `Accept: application/json` for all requests and `Content-Type: application/json` for requests with a body (although the current controller only exposes GET endpoints). +- The controller exposes read endpoints only: listing events (optionally filtered) and listing event types. +- Query parameter name for filtering by superior group is `g` (single-valued, optional). +- Dates in DTOs are represented as JSON date/time values; the service uses `java.util.Date` (ISO-8601 strings are accepted by most Jackson configurations). + +## Endpoints + +### 1) GET `/` (list events) +- Path: +```http +GET / +Host: localhost:8080 +``` +- Description: Retrieve events, optionally filtered by a superior group identifier. +- Query parameters: + - `g` (optional) — superior group id (String). If provided, the endpoint returns events for that superior group; otherwise it returns unfiltered or default results. +- Returns: 200 OK with a JSON array of `EventDTO` objects. + +Example request (HTTP-style): +```http +GET http://localhost:8080/pkwmtt/api/v1/events +Accept: application/json +``` + +Example request with filter: +```http +GET http://localhost:8080/pkwmtt/api/v1/events?g=12K1 +Accept: application/json +``` + +Curl example (Windows / cmd): +``` +curl -v "http://localhost:8080/pkwmtt/api/v1/events?g=12K1" -H "Accept: application/json" +``` + +### 2) GET `/types` (list available event types) +- Path & example: +```http +GET /types +Host: localhost:8080 +``` +- Description: Retrieve all distinct event type names. +- Returns: 200 OK with a JSON array of strings representing event type names. + +Example: +```http +GET http://localhost:8080/pkwmtt/api/v1/events/types +Accept: application/json +``` + +Curl example (Windows / cmd): +``` +curl -v "http://localhost:8080/pkwmtt/api/v1/events/types" -H "Accept: application/json" +``` + +## Payload shapes + +EventDTO (`org.pkwmtt.calendar.events.dto.EventDTO`) +- Fields and notes: + - `title` (String) + - `description` (String) — optional + - `startDate` (Date) — mapped from `java.util.Date`; JSON representation depends on Jackson config (ISO-8601 recommended) + - `endDate` (Date) + - `type` (String) — event type name + - `superiorGroups` (List) — list of superior group identifiers associated with the event + +Example JSON (single EventDTO): +```json +{ + "title": "Parent-teacher meeting", + "description": "End of term meeting", + "startDate": "2025-12-10T17:00:00Z", + "endDate": "2025-12-10T19:00:00Z", + "type": "MEETING", + "superiorGroups": ["12K1", "12K2"] +} +``` + +Notes on entities and mapping +- `org.pkwmtt.calendar.events.entities.Event` stores: + - `id` (int, DB-generated) + - `title`, `description`, `startDate`, `endDate` + - `type` (ManyToOne to `EventType`) + - `superiorGroups` (ManyToMany to `SuperiorGroup`) +- `EventsMapper` converts `Event` entities to `EventDTO` objects, extracting the event type name and converting `SuperiorGroup` entities into their `name` values for `superiorGroups`. +- Mapping from `EventDTO` to `Event` sets core fields only (title, description, startDate, endDate); resolving the `type` entity and `superiorGroups` relationships is responsibility of the service layer. + +Error handling +- The `EventsController` currently exposes only GET endpoints; error handling for invalid parameters or unexpected errors will follow the application's global exception handling (controller advice) if present. + +Where to look in the codebase for details: +- Controller: `src/main/java/org/pkwmtt/calendar/events/controllers/EventsController.java` +- DTO: `src/main/java/org/pkwmtt/calendar/events/dto/EventDTO.java` +- Entity and mapper: `src/main/java/org/pkwmtt/calendar/events/entities/Event.java`, `EventType.java`, and `src/main/java/org/pkwmtt/calendar/events/mappers/EventsMapper.java` +- Service: `src/main/java/org/pkwmtt/calendar/events/services/EventsService.java` + + diff --git a/src/main/java/org/pkwmtt/calendar/EXAMCALENDAR.MD b/src/main/java/org/pkwmtt/calendar/EXAMCALENDAR.MD new file mode 100644 index 0000000..6d26642 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/EXAMCALENDAR.MD @@ -0,0 +1,258 @@ +# Exam Calendar — API Reference + +This document explains the REST endpoints exposed by `ExamController`. + +Base URL: + +http://localhost:8080/pkwmtt/api/v1/exams + +Summary / quick checklist +- Use `Accept: application/json` for all requests and `Content-Type: application/json` for requests with a body. +- Requests are validated (see `RequestExamDto` constraints). Failed validations return 400 with an `ErrorResponseDTO` describing the issues. +- POST returns 201 Created with a `Location` header pointing to the created resource URI (the controller builds a URI of the form `.../exams/{id}`). +- GET `/by-groups` returns a list of exams filtered by provided general groups (required) and optional subgroups. + +## Endpoints + +### 1) POST `""` (add exam) +- Path: +```http +POST / +Host: localhost:8080 +``` +- Description: Create a new exam/test. +- Request body: `RequestExamDto` JSON (validated). +- Success: 201 Created with `Location` header pointing to `.../exams/{id}`. +- Errors: 400 Bad Request for validation or bad input, 409 Conflict when resource already exists. + +Example request (HTTP-style): +```http +POST http://localhost:8080/pkwmtt/api/v1/exams +Content-Type: application/json +Accept: application/json +``` + +Request body (JSON): +```json +{ + "title": "Math final", + "description": "Final exam for semester", + "date": "2025-12-18T09:00:00", + "examType": "EXAM", + "generalGroups": ["12K1", "12K2"], + "subgroups": ["K01", "L01"] +} +``` + +Curl example (Windows / cmd): +``` +curl -v -X POST "http://localhost:8080/pkwmtt/api/v1/exams" \ + -H "Content-Type: application/json" \ + -d "{\"title\":\"Math final\",\"description\":\"Final exam for semester\",\"date\":\"2025-12-18T09:00:00\",\"examType\":\"EXAM\",\"generalGroups\":[\"12K1\",\"12K2\"],\"subgroups\":[\"K01\",\"L01\"]}" +``` + + +### 2) PUT `/{id}` (modify exam) +- Path: +```http +PUT /{id} +Host: localhost:8080 +``` +- Description: Modify an existing exam. `id` must be a positive integer. +- Request body: `RequestExamDto` JSON (validated). +- Success: 204 No Content. +- Errors: 400 Bad Request for validation or invalid group/type, 404 Not Found if `id` doesn't exist. + +Example request (HTTP-style): +```http +PUT http://localhost:8080/pkwmtt/api/v1/exams/123 +Content-Type: application/json +Accept: application/json +``` + +Request body (JSON): +```json +{ + "title": "Math final - updated", + "description": "Updated description", + "date": "2025-12-18T10:00:00", + "examType": "EXAM", + "generalGroups": ["12K1"], + "subgroups": ["K01"] +} +``` + +Curl example (Windows / cmd): +``` +curl -v -X PUT "http://localhost:8080/pkwmtt/api/v1/exams/123" \ + -H "Content-Type: application/json" \ + -d "{\"title\":\"Math final - updated\",\"description\":\"Updated description\",\"date\":\"2025-12-18T10:00:00\",\"examType\":\"EXAM\",\"generalGroups\": [\"12K1\"],\"subgroups\":[\"K01\"]}" +``` + + +### 3) DELETE `/{id}` (delete exam) +- Path: +```http +DELETE /{id} +Host: localhost:8080 +``` +- Description: Remove an exam by its id. +- Success: 204 No Content. +- Errors: 404 Not Found if the exam id doesn't exist. + +Example (HTTP-style): +```http +DELETE http://localhost:8080/pkwmtt/api/v1/exams/123 +``` + +Curl example (Windows / cmd): +``` +curl -v -X DELETE "http://localhost:8080/pkwmtt/api/v1/exams/123" +``` + + +### 4) GET `/by-groups` (list exams for groups) +- Path & query params: +```http +GET /by-groups?generalGroups={g1}&generalGroups={g2}&subgroups={s1}&subgroups={s2} +Host: localhost:8080 +``` +- Required query param: `generalGroups` — repeatable (Set). E.g. `?generalGroups=12K1&generalGroups=12K2`. +- Optional query param: `subgroups` — repeatable (Set), filter exams to specific subgroups. +- Returns: 200 OK with JSON array of `ResponseExamDto` objects. + +Example request (HTTP-style): +```http +GET http://localhost:8080/pkwmtt/api/v1/exams/by-groups?generalGroups=12K1&generalGroups=12K2 +Accept: application/json +``` + +Curl example (Windows / cmd): +``` +curl -v "http://localhost:8080/pkwmtt/api/v1/exams/by-groups?generalGroups=12K1&generalGroups=12K2" -H "Accept: application/json" +``` + + +### 5) GET `/exam-types` (list available exam types) +- Path & example: +```http +GET /exam-types +Host: localhost:8080 +``` +- Returns: 200 OK with a JSON array of `ExamType` objects (enum/entity type; typically contains `name` and id fields depending on serialization). + +Example: +```http +GET http://localhost:8080/pkwmtt/api/v1/exams/exam-types +Accept: application/json +``` + + +Payload shapes + +RequestExamDto (`org.pkwmtt.calendar.dto.RequestExamDto`) +- Fields and validation: + - `title` (String) — @NotBlank, max 255 + - `description` (String) — optional, max 255 + - `date` (LocalDateTime) — must satisfy `@CorrectFutureDate` (custom validator ensuring future date) + - `examType` (String) — @NotNull (must match a known exam type) + - `generalGroups` (Set) — @NotEmpty, at least 1 element + - `subgroups` (Set) — optional + +Example JSON: +```json +{ + "title": "Math final", + "description": "Final exam for semester", + "date": "2025-12-18T09:00:00", + "examType": "EXAM", + "generalGroups": ["12K1", "12K2"], + "subgroups": ["K01", "L01"] +} +``` + +ResponseExamDto (`org.pkwmtt.calendar.dto.ResponseExamDto`) +- Extends `RequestExamDto` and adds: + - `examId` (int) + +Example JSON: +```json +{ + "examId": 123, + "title": "Math final", + "description": "Final exam for semester", + "date": "2025-12-18T09:00:00", + "examType": "EXAM", + "generalGroups": ["12K1", "12K2"], + "subgroups": ["K01", "L01"] +} +``` + +Exam entity notes +- `org.pkwmtt.calendar.exams.entity.Exam` stores: + - `examId` (Integer, DB-generated) + - `title`, `description`, `examDate`, `examType` (ManyToOne to `ExamType`), and a Set of `StudentGroup`s (ManyToMany). +- The builder enforces groups count between 1 and 100; creating an `Exam` with 0 or more than 100 groups will trigger an `UnsupportedCountOfArgumentsException`. +- Mapping details: `ExamDtoMapper` converts the `groups` set into `generalGroups` (entries starting with a digit) and `subgroups` (entries starting with a letter). Some group names that don't match either rule may be skipped with a logged warning. + + +Error handling and Controller Advice + +`ExamControllerAdvice` maps exceptions to HTTP responses as follows (see `src/main/java/org/pkwmtt/examCalendar/ExamControllerAdvice.java`): + +- 404 Not Found + - Exception: `NoSuchElementWithProvidedIdException` + - Body: `ErrorResponseDTO` with `message` and `timestamp`. + +- 400 Bad Request + - Exceptions: `ExamTypeNotExistsException`, `InvalidGroupIdentifierException`, `SpecifiedGeneralGroupDoesntExistsException`, `SpecifiedSubGroupDoesntExistsException`, `UnsupportedCountOfArgumentsException` + - Also handles: `MethodArgumentNotValidException` (returns concatenated field errors) and `ConstraintViolationException` (validation messages for path/params). + - Body: `ErrorResponseDTO` with a human-readable message. + +- 409 Conflict + - Exception: `ResourceAlreadyExistsException` — returned when attempting to create a resource that already exists. + +ErrorResponseDTO fields (shared across handlers): +``` +message: string +timestamp: string +``` + +Example 400 response (validation): +```json +{ + "message": "title : must not be blank, generalGroups : must not be empty", + "timestamp": "2025-11-03T12:34:56.789" +} +``` + +Example 404 response: +```json +{ + "message": "No exam found with id: 123", + "timestamp": "2025-11-03T12:34:56.789" +} +``` + + +Frontend integration notes and gotchas +- `generalGroups` is required for the GET `/by-groups` endpoint and for creating/updating exams. Ensure group identifiers match those returned by your groups API or database. +- The `date` field must be a future date as validated by `@CorrectFutureDate`. +- When calling POST, a `Location` header is returned pointing to `.../exams/{id}`; however, the controller does not expose a GET-by-id endpoint — the header is provided by convention. +- The backend enforces 1..100 groups per exam; avoid sending an empty `generalGroups` or a too-large payload. + + +Where to look in the codebase for details: +- Controller: `src/main/java/org/pkwmtt/calendar/exams/controllers/ExamController.java` +- Controller advice: `src/main/java/org/pkwmtt/calendar/exams/controllers/ExamControllerAdvice.java` +- DTOs: `src/main/java/org/pkwmtt/calendar/exams/dto/RequestExamDto.java`, `ResponseExamDto.java` +- Entity and mapper: `src/main/java/org/pkwmtt/calendar/exams/entity/Exam.java`, `ExamType.java`, and `src/main/java/org/pkwmtt/calendar/exams/mapper/ExamDtoMapper.java` + +Troubleshooting +- 400 Bad Request: check field validation messages returned in `ErrorResponseDTO` and ensure `generalGroups` is non-empty and `date` is in the future. +- 404 Not Found: verify the `id` exists before attempting to modify/delete, or check group names used in queries. +- 409 Conflict: avoid creating duplicate exams with identical identifying fields. + +Change log +- 2025-11-03 — initial documentation added for `ExamController` including payload examples, validation rules, and error mappings. + diff --git a/src/main/java/org/pkwmtt/calendar/adnotations/CorrectFutureDate.java b/src/main/java/org/pkwmtt/calendar/adnotations/CorrectFutureDate.java new file mode 100644 index 0000000..4e99ab1 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/adnotations/CorrectFutureDate.java @@ -0,0 +1,20 @@ +package org.pkwmtt.calendar.adnotations; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = CorrectFutureDateValidator.class) +@Target({ ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface CorrectFutureDate { + + String message() default "Wrong date!"; + Class[] groups() default {}; + Class[] payload() default {}; + +} diff --git a/src/main/java/org/pkwmtt/calendar/adnotations/CorrectFutureDateValidator.java b/src/main/java/org/pkwmtt/calendar/adnotations/CorrectFutureDateValidator.java new file mode 100644 index 0000000..5b2dc40 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/adnotations/CorrectFutureDateValidator.java @@ -0,0 +1,38 @@ +package org.pkwmtt.calendar.adnotations; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.time.LocalDateTime; + +import static java.util.Objects.isNull; + +public class CorrectFutureDateValidator implements ConstraintValidator { + + @Override + public boolean isValid(LocalDateTime time, ConstraintValidatorContext constraintValidatorContext) { + if (isNull(time)) { + setMessage(constraintValidatorContext, "must not be null"); + return false; + } + + if (time.isBefore(LocalDateTime.now())){ + setMessage(constraintValidatorContext, "Date must be in the future"); + return false; + } + + //TODO Date need to be extracted to f.e DB (this date is end of semester, maybe have to change to +1 month after end of semester) + if (time.isAfter(LocalDateTime.of(2026, 2, 22, 0, 0))) { + setMessage(constraintValidatorContext, "Date is too far in the future"); + return false; + } + + return true; + } + + private void setMessage(ConstraintValidatorContext context, String message) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(message) + .addConstraintViolation(); + } +} diff --git a/src/main/java/org/pkwmtt/examCalendar/entity/GeneralGroup.java b/src/main/java/org/pkwmtt/calendar/enities/SuperiorGroup.java similarity index 65% rename from src/main/java/org/pkwmtt/examCalendar/entity/GeneralGroup.java rename to src/main/java/org/pkwmtt/calendar/enities/SuperiorGroup.java index c32e545..11cd858 100644 --- a/src/main/java/org/pkwmtt/examCalendar/entity/GeneralGroup.java +++ b/src/main/java/org/pkwmtt/calendar/enities/SuperiorGroup.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar.entity; +package org.pkwmtt.calendar.enities; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -11,12 +11,12 @@ @Builder @AllArgsConstructor @NoArgsConstructor -@Table(name = "general_group") -public class GeneralGroup { +@Table(name = "superior_groups") +public class SuperiorGroup { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "general_group_id") - private Integer generalGroupId; + @Column(name = "superior_group_id") + private Integer superiorGroupId; @Column(nullable = false) private String name; diff --git a/src/main/java/org/pkwmtt/calendar/events/controllers/EventsController.java b/src/main/java/org/pkwmtt/calendar/events/controllers/EventsController.java new file mode 100644 index 0000000..42390db --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/events/controllers/EventsController.java @@ -0,0 +1,56 @@ +package org.pkwmtt.calendar.events.controllers; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.calendar.events.dto.EventDTO; +import org.pkwmtt.calendar.events.services.EventsService; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * REST controller that exposes endpoints for working with calendar events. + *

+ * Requests are prefixed with the configurable property {@code apiPrefix}. + * Delegates business logic to {@link EventsService}. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("${apiPrefix}/events") +public class EventsController { + /** + * Service providing event-related operations. Injected via Lombok's + * {@code @RequiredArgsConstructor}. + */ + final EventsService service; + + /** + * Retrieve events optionally filtered by a superior group identifier. + * + * @param superiorGroup optional query parameter (name = "g") representing the superior group id. + * If {@code null}, the service is called with {@code null} and is expected + * to return all events or the appropriate unfiltered result. + * @return HTTP 200 with a list of {@link EventDTO} matching the filter (or all events when no filter provided). + */ + @GetMapping + public ResponseEntity> getAllEvents (@RequestParam(required = false, name = "g") String superiorGroup) { + var response = superiorGroup != null + ? service.getEventsForSuperiorGroup(superiorGroup) + : service.getAllEvents(); + + return ResponseEntity.ok().body(response); + } + + /** + * Retrieve all distinct event types. + * + * @return HTTP 200 with a list of event type names. + */ + @GetMapping("/types") + public ResponseEntity> getAllEventTypes () { + return ResponseEntity.ok().body(service.getAllEventTypes()); + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/calendar/events/dto/EventDTO.java b/src/main/java/org/pkwmtt/calendar/events/dto/EventDTO.java new file mode 100644 index 0000000..fd3256d --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/events/dto/EventDTO.java @@ -0,0 +1,25 @@ +package org.pkwmtt.calendar.events.dto; + +import lombok.Data; +import lombok.experimental.Accessors; +import org.pkwmtt.calendar.adnotations.CorrectFutureDate; + +import java.util.Date; +import java.util.List; + +@Data +@Accessors(chain = true) +public class EventDTO { + int id; + String title; + String description; + + @CorrectFutureDate + Date startDate; + + @CorrectFutureDate + Date endDate; + + String type; + List superiorGroups; +} diff --git a/src/main/java/org/pkwmtt/calendar/events/entities/Event.java b/src/main/java/org/pkwmtt/calendar/events/entities/Event.java new file mode 100644 index 0000000..16abbe5 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/events/entities/Event.java @@ -0,0 +1,57 @@ +package org.pkwmtt.calendar.events.entities; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.pkwmtt.calendar.enities.SuperiorGroup; + +import java.util.Date; +import java.util.List; + +@Entity +@Table(name = "events") +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class Event { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "event_id") + int id; + + @Column(name = "title") + String title; + + @Column(name = "description") + String description; + + @Column(name = "start_date") + Date startDate; + + @Column(name = "end_date") + Date endDate; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "type", referencedColumnName = "event_type_id") + EventType type; + + @ManyToMany + @JoinTable( + name = "events_superior_group", + joinColumns = @JoinColumn(name = "event_id"), + inverseJoinColumns = @JoinColumn(name = "superior_group_id") + ) + List superiorGroups; + + + public Event (int id, String title, String description, EventType type, Date startDate, Date endDate) { + this.id = id; + this.title = title; + this.description = description; + this.type = type; + this.startDate = startDate; + this.endDate = endDate; + } +} diff --git a/src/main/java/org/pkwmtt/calendar/events/entities/EventType.java b/src/main/java/org/pkwmtt/calendar/events/entities/EventType.java new file mode 100644 index 0000000..e8c482f --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/events/entities/EventType.java @@ -0,0 +1,21 @@ +package org.pkwmtt.calendar.events.entities; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "event_types") +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class EventType { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "event_type_id") + private int id; + + @Column(name = "name") + private String name; +} diff --git a/src/main/java/org/pkwmtt/calendar/events/mappers/EventsMapper.java b/src/main/java/org/pkwmtt/calendar/events/mappers/EventsMapper.java new file mode 100644 index 0000000..5d1e3c7 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/events/mappers/EventsMapper.java @@ -0,0 +1,82 @@ +package org.pkwmtt.calendar.events.mappers; + +import org.pkwmtt.calendar.enities.SuperiorGroup; +import org.pkwmtt.calendar.events.dto.EventDTO; +import org.pkwmtt.calendar.events.entities.Event; +import org.pkwmtt.calendar.events.entities.EventType; + +import java.util.List; + +/** + * Utility mapper for converting between Event entities, EventType entities and EventDTOs. + *

+ * All methods are static and stateless; this class provides a centralized place for + * transformation logic used when exchanging data between persistence/entities and DTOs. + */ +public class EventsMapper { + + /** + * Map an {@link Event} entity to an {@link EventDTO}. + *

+ * The mapping includes: + * - title + * - description + * - type name + * - start and end dates + * - superior group names (converted from {@link SuperiorGroup}) + *

+ * Note: this method assumes that required nested properties (like {@code event.getType()} + * and {@code event.getSuperiorGroups()}) are present; callers should handle potential + * {@code null} values if needed. + * + * @param event the source Event entity to map + * @return a populated EventDTO representing the given Event + */ + public static EventDTO mapEventToEventDTO (Event event) { + return new EventDTO() + .setId(event.getId()) + .setTitle(event.getTitle()) + .setDescription(event.getDescription()) + .setType(event.getType().getName()) + .setStartDate(event.getStartDate()) + .setEndDate(event.getEndDate()) + .setSuperiorGroups(event.getSuperiorGroups().stream().map(SuperiorGroup::getName).toList() + ); + + } + + /** + * Map an {@link EventDTO} and a resolved {@link EventType} into a new {@link Event} entity + * Assumes {@code eventDTO} is non-null. The provided {@code type} is assigned to the + * created Event. This method does not populate superior groups; callers should set + * them on the returned Event if required. + * + * @param eventDTO DTO containing event fields to map + * @param type resolved EventType to attach to the created Event + * @return a new Event populated from the DTO and provided type + */ + public static Event mapEventDTOToEvent (EventDTO eventDTO, EventType type) { + + return new Event( + eventDTO.getId(), + eventDTO.getTitle(), + eventDTO.getDescription(), + type, + eventDTO.getStartDate(), + eventDTO.getEndDate() + ); + } + + /** + * Convert a list of {@link EventType} entities to a list of their names. + *

+ * Example usage: converting persisted event types to a simple list of strings for DTOs + * or UI consumption. + * + * @param eventTypes list of EventType entities to convert + * @return list of names extracted from the provided eventTypes + */ + public static List mapEventTypeListToListOfString (List eventTypes) { + return eventTypes.stream().map(EventType::getName).toList(); + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/calendar/events/repositories/EventTypeRepository.java b/src/main/java/org/pkwmtt/calendar/events/repositories/EventTypeRepository.java new file mode 100644 index 0000000..156284c --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/events/repositories/EventTypeRepository.java @@ -0,0 +1,12 @@ +package org.pkwmtt.calendar.events.repositories; + +import org.pkwmtt.calendar.events.entities.EventType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + + +@SuppressWarnings("unused") +public interface EventTypeRepository extends JpaRepository { + Optional findByName (String name); +} diff --git a/src/main/java/org/pkwmtt/calendar/events/repositories/EventsRepository.java b/src/main/java/org/pkwmtt/calendar/events/repositories/EventsRepository.java new file mode 100644 index 0000000..78e0630 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/events/repositories/EventsRepository.java @@ -0,0 +1,7 @@ +package org.pkwmtt.calendar.events.repositories; + +import org.pkwmtt.calendar.events.entities.Event; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventsRepository extends JpaRepository { +} diff --git a/src/main/java/org/pkwmtt/calendar/events/services/EventsService.java b/src/main/java/org/pkwmtt/calendar/events/services/EventsService.java new file mode 100644 index 0000000..b4e2525 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/events/services/EventsService.java @@ -0,0 +1,111 @@ +package org.pkwmtt.calendar.events.services; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.calendar.events.dto.EventDTO; +import org.pkwmtt.calendar.events.mappers.EventsMapper; +import org.pkwmtt.calendar.events.repositories.EventTypeRepository; +import org.pkwmtt.calendar.events.repositories.EventsRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Service layer for managing events. + * + *

Provides methods to retrieve events, filter events by superior group, + * add new events and obtain available event types. This service delegates + * persistence operations to the {@code EventsRepository} and uses + * {@code EventsMapper} to convert between entity and DTO representations. + * + *

Constructor is generated by Lombok's {@code @RequiredArgsConstructor}. + */ +@Service +@RequiredArgsConstructor +public class EventsService { + + private final EventsRepository eventsRepository; + + private final EventTypeRepository eventTypeRepository; + + /** + * Retrieve all events as DTOs. + * + * @return list of all events converted to {@link EventDTO} + */ + public List getAllEvents () { + return eventsRepository.findAll() + .stream() + .map(EventsMapper::mapEventToEventDTO) + .toList(); + } + + /** + * Retrieve events that belong to a superior group with the provided name. + * + *

The match is case-insensitive. + * + * @param superiorGroupName name of the superior group to filter by + * @return list of events matching the superior group converted to {@link EventDTO} + */ + public List getEventsForSuperiorGroup (String superiorGroupName) { + return eventsRepository.findAll() + .stream() + .filter(item -> item.getSuperiorGroups() + .stream() + .anyMatch(group -> group.getName().equalsIgnoreCase(superiorGroupName))) + .map(EventsMapper::mapEventToEventDTO) + .toList(); + } + + /** + * Persist a new event based on the provided DTO. + * + *

The DTO is mapped to the entity type, saved and the generated id is returned. + * + * @param eventDTO DTO containing event data to persist + * @return generated id of the saved event + */ + public int addEvent (EventDTO eventDTO) { + var eventType = eventTypeRepository.findByName(eventDTO.getType()); + + if (eventType.isEmpty()) { + throw new IllegalArgumentException("Invalid event type: " + eventDTO.getType()); + } + + var event = EventsMapper.mapEventDTOToEvent(eventDTO, eventType.get()); + eventsRepository.save(event); + return event.getId(); + } + + /** + * Retrieve all available event types as strings. + * + * @return list of event type names + */ + public List getAllEventTypes () { + return EventsMapper.mapEventTypeListToListOfString(eventTypeRepository.findAll()); + } + + /** + * Update an existing event using the provided DTO. + * + *

Validates the DTO's event type exists, maps the DTO to an entity and + * persists the entity. This method is executed within a transaction so the + * update will be committed or rolled back atomically. + * + * @param eventDTO DTO containing the updated event data + * @throws IllegalArgumentException if the provided event type does not exist + */ + @Transactional + public void updateEvent (EventDTO eventDTO) { + var eventType = eventTypeRepository + .findByName(eventDTO.getType()).orElseThrow(() -> + new IllegalArgumentException( + "Invalid event type: " + eventDTO.getType()) + ); + + var eventEntity = EventsMapper.mapEventDTOToEvent(eventDTO, eventType); + eventsRepository.save(eventEntity); + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/examCalendar/ExamController.java b/src/main/java/org/pkwmtt/calendar/exams/controllers/ExamController.java similarity index 78% rename from src/main/java/org/pkwmtt/examCalendar/ExamController.java rename to src/main/java/org/pkwmtt/calendar/exams/controllers/ExamController.java index 3fb36d5..01a142f 100644 --- a/src/main/java/org/pkwmtt/examCalendar/ExamController.java +++ b/src/main/java/org/pkwmtt/calendar/exams/controllers/ExamController.java @@ -1,14 +1,13 @@ -package org.pkwmtt.examCalendar; +package org.pkwmtt.calendar.exams.controllers; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; -import org.pkwmtt.examCalendar.dto.RequestExamDto; -import org.pkwmtt.examCalendar.dto.ResponseExamDto; -import org.pkwmtt.examCalendar.entity.Exam; -import org.pkwmtt.examCalendar.entity.ExamType; -import org.pkwmtt.examCalendar.mapper.ExamDtoMapper; +import org.pkwmtt.calendar.exams.services.ExamService; +import org.pkwmtt.calendar.exams.dto.RequestExamDto; +import org.pkwmtt.calendar.exams.dto.ResponseExamDto; +import org.pkwmtt.calendar.exams.entity.ExamType; +import org.pkwmtt.calendar.exams.mapper.ExamDtoMapper; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -31,7 +30,6 @@ public class ExamController { * @return 201 created with URI to GET method which returns created resource */ @PostMapping("") - @SecurityRequirement(name = "bearerAuth") public ResponseEntity addExam(@RequestBody @Valid RequestExamDto requestExamDto){ int id = examService.addExam(requestExamDto); URI uri = ServletUriComponentsBuilder @@ -48,7 +46,6 @@ public ResponseEntity addExam(@RequestBody @Valid RequestExamDto requestEx * @return 204 no content */ @PutMapping("/{id}") - @SecurityRequirement(name = "bearerAuth") public ResponseEntity modifyExam(@PathVariable @Positive int id, @RequestBody @Valid RequestExamDto requestExamDto) { examService.modifyExam(requestExamDto, id); return ResponseEntity.noContent().build(); @@ -59,20 +56,11 @@ public ResponseEntity modifyExam(@PathVariable @Positive int id, @RequestB * @return 204 no content */ @DeleteMapping("/{id}") - @SecurityRequirement(name = "bearerAuth") 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 diff --git a/src/main/java/org/pkwmtt/examCalendar/ExamControllerAdvice.java b/src/main/java/org/pkwmtt/calendar/exams/controllers/ExamControllerAdvice.java similarity index 98% rename from src/main/java/org/pkwmtt/examCalendar/ExamControllerAdvice.java rename to src/main/java/org/pkwmtt/calendar/exams/controllers/ExamControllerAdvice.java index ea77710..4bc669d 100644 --- a/src/main/java/org/pkwmtt/examCalendar/ExamControllerAdvice.java +++ b/src/main/java/org/pkwmtt/calendar/exams/controllers/ExamControllerAdvice.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar; +package org.pkwmtt.calendar.exams.controllers; import jakarta.validation.ConstraintViolationException; import org.pkwmtt.exceptions.*; diff --git a/src/main/java/org/pkwmtt/examCalendar/dto/RequestExamDto.java b/src/main/java/org/pkwmtt/calendar/exams/dto/RequestExamDto.java similarity index 84% rename from src/main/java/org/pkwmtt/examCalendar/dto/RequestExamDto.java rename to src/main/java/org/pkwmtt/calendar/exams/dto/RequestExamDto.java index 5fbd3ac..738c43e 100644 --- a/src/main/java/org/pkwmtt/examCalendar/dto/RequestExamDto.java +++ b/src/main/java/org/pkwmtt/calendar/exams/dto/RequestExamDto.java @@ -1,9 +1,10 @@ -package org.pkwmtt.examCalendar.dto; +package org.pkwmtt.calendar.exams.dto; import jakarta.validation.constraints.*; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.experimental.SuperBuilder; +import org.pkwmtt.calendar.adnotations.CorrectFutureDate; import java.time.LocalDateTime; import java.util.Set; @@ -20,8 +21,7 @@ public class RequestExamDto { @Size(max = 255, message = "max size of field is 255") private String description; - @Future(message = "Date must be in the future") - @NotNull + @CorrectFutureDate private LocalDateTime date; @NotNull diff --git a/src/main/java/org/pkwmtt/examCalendar/dto/ResponseExamDto.java b/src/main/java/org/pkwmtt/calendar/exams/dto/ResponseExamDto.java similarity index 81% rename from src/main/java/org/pkwmtt/examCalendar/dto/ResponseExamDto.java rename to src/main/java/org/pkwmtt/calendar/exams/dto/ResponseExamDto.java index 07584d9..c47d5f2 100644 --- a/src/main/java/org/pkwmtt/examCalendar/dto/ResponseExamDto.java +++ b/src/main/java/org/pkwmtt/calendar/exams/dto/ResponseExamDto.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar.dto; +package org.pkwmtt.calendar.exams.dto; import lombok.Getter; import lombok.experimental.SuperBuilder; diff --git a/src/main/java/org/pkwmtt/examCalendar/entity/Exam.java b/src/main/java/org/pkwmtt/calendar/exams/entity/Exam.java similarity index 98% rename from src/main/java/org/pkwmtt/examCalendar/entity/Exam.java rename to src/main/java/org/pkwmtt/calendar/exams/entity/Exam.java index 31d115a..ab61761 100644 --- a/src/main/java/org/pkwmtt/examCalendar/entity/Exam.java +++ b/src/main/java/org/pkwmtt/calendar/exams/entity/Exam.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar.entity; +package org.pkwmtt.calendar.exams.entity; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -36,7 +36,7 @@ public class Exam { @ManyToOne @JoinColumn(name = "exam_type_id", nullable = false) private ExamType examType; - + @ManyToMany @JoinTable( name="exams_groups", diff --git a/src/main/java/org/pkwmtt/calendar/exams/entity/ExamGroup.java b/src/main/java/org/pkwmtt/calendar/exams/entity/ExamGroup.java new file mode 100644 index 0000000..1410160 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/exams/entity/ExamGroup.java @@ -0,0 +1,25 @@ +package org.pkwmtt.calendar.exams.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "exams_groups") +public class ExamGroup { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "exam_group_id") + private Integer examGroupId; + + @Column(name = "exam_id", nullable = false) + private Integer examId; + + @Column(name = "group_id", nullable = false) + private Integer groupId; +} + diff --git a/src/main/java/org/pkwmtt/examCalendar/entity/ExamType.java b/src/main/java/org/pkwmtt/calendar/exams/entity/ExamType.java similarity index 86% rename from src/main/java/org/pkwmtt/examCalendar/entity/ExamType.java rename to src/main/java/org/pkwmtt/calendar/exams/entity/ExamType.java index 90a9f74..f381a80 100644 --- a/src/main/java/org/pkwmtt/examCalendar/entity/ExamType.java +++ b/src/main/java/org/pkwmtt/calendar/exams/entity/ExamType.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar.entity; +package org.pkwmtt.calendar.exams.entity; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -11,7 +11,7 @@ @Builder @AllArgsConstructor @RequiredArgsConstructor -@Table(name = "exam_type") +@Table(name = "exam_types") public class ExamType { @Id diff --git a/src/main/java/org/pkwmtt/calendar/exams/entity/Representative.java b/src/main/java/org/pkwmtt/calendar/exams/entity/Representative.java new file mode 100644 index 0000000..b595a7c --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/exams/entity/Representative.java @@ -0,0 +1,37 @@ +package org.pkwmtt.calendar.exams.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.pkwmtt.calendar.enities.SuperiorGroup; + +import java.util.UUID; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "representatives") +public class Representative { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "representative_id") + @JdbcTypeCode(SqlTypes.VARCHAR) + private UUID representativeId; + + @ManyToOne + @JoinColumn(name = "superior_group_id", nullable = false) + private SuperiorGroup superiorGroup; + + @Column(nullable = false) + private String email; + + @Column(name = "is_active", nullable = false) + private boolean isActive; +} + diff --git a/src/main/java/org/pkwmtt/calendar/exams/entity/StudentCode.java b/src/main/java/org/pkwmtt/calendar/exams/entity/StudentCode.java new file mode 100644 index 0000000..bcb576f --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/exams/entity/StudentCode.java @@ -0,0 +1,48 @@ +package org.pkwmtt.calendar.exams.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.pkwmtt.calendar.enities.SuperiorGroup; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "student_codes") +public class StudentCode { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "student_code_id") + private Integer studentCodeId; + + @Column(nullable = false) + private String code; + + @Column(nullable = false) + private LocalDateTime expire; + + @ManyToOne + @JoinColumn(name = "superior_group_id", nullable = false) + private SuperiorGroup superiorGroup; + + @Column(name = "usage_count") + private Integer usage; + + @Column(name = "usage_limit") + private Integer usageLimit; + + + public StudentCode (String code, SuperiorGroup superiorGroup) { + this.code = code; + this.superiorGroup = superiorGroup; + this.expire = LocalDateTime.now().plusDays(1); + this.usage = 0; + this.usageLimit = 99; + } +} diff --git a/src/main/java/org/pkwmtt/examCalendar/entity/StudentGroup.java b/src/main/java/org/pkwmtt/calendar/exams/entity/StudentGroup.java similarity index 90% rename from src/main/java/org/pkwmtt/examCalendar/entity/StudentGroup.java rename to src/main/java/org/pkwmtt/calendar/exams/entity/StudentGroup.java index 158cce6..8fd773c 100644 --- a/src/main/java/org/pkwmtt/examCalendar/entity/StudentGroup.java +++ b/src/main/java/org/pkwmtt/calendar/exams/entity/StudentGroup.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar.entity; +package org.pkwmtt.calendar.exams.entity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/org/pkwmtt/calendar/exams/enums/Role.java b/src/main/java/org/pkwmtt/calendar/exams/enums/Role.java new file mode 100644 index 0000000..d9e14a2 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/exams/enums/Role.java @@ -0,0 +1,8 @@ +package org.pkwmtt.calendar.exams.enums; + +public enum Role { + ADMIN, + REPRESENTATIVE, + MODERATOR, + STUDENT +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/examCalendar/enums/SubjectType.java b/src/main/java/org/pkwmtt/calendar/exams/enums/SubjectType.java similarity index 76% rename from src/main/java/org/pkwmtt/examCalendar/enums/SubjectType.java rename to src/main/java/org/pkwmtt/calendar/exams/enums/SubjectType.java index 0aad3d5..0b6e70f 100644 --- a/src/main/java/org/pkwmtt/examCalendar/enums/SubjectType.java +++ b/src/main/java/org/pkwmtt/calendar/exams/enums/SubjectType.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar.enums; +package org.pkwmtt.calendar.exams.enums; public enum SubjectType { LECTURE, diff --git a/src/main/java/org/pkwmtt/examCalendar/mapper/ExamDtoMapper.java b/src/main/java/org/pkwmtt/calendar/exams/mapper/ExamDtoMapper.java similarity index 91% rename from src/main/java/org/pkwmtt/examCalendar/mapper/ExamDtoMapper.java rename to src/main/java/org/pkwmtt/calendar/exams/mapper/ExamDtoMapper.java index 71bbbdb..f5377c5 100644 --- a/src/main/java/org/pkwmtt/examCalendar/mapper/ExamDtoMapper.java +++ b/src/main/java/org/pkwmtt/calendar/exams/mapper/ExamDtoMapper.java @@ -1,11 +1,11 @@ -package org.pkwmtt.examCalendar.mapper; +package org.pkwmtt.calendar.exams.mapper; import lombok.extern.slf4j.Slf4j; -import org.pkwmtt.examCalendar.dto.RequestExamDto; -import org.pkwmtt.examCalendar.dto.ResponseExamDto; -import org.pkwmtt.examCalendar.entity.Exam; -import org.pkwmtt.examCalendar.entity.ExamType; -import org.pkwmtt.examCalendar.entity.StudentGroup; +import org.pkwmtt.calendar.exams.dto.RequestExamDto; +import org.pkwmtt.calendar.exams.dto.ResponseExamDto; +import org.pkwmtt.calendar.exams.entity.Exam; +import org.pkwmtt.calendar.exams.entity.ExamType; +import org.pkwmtt.calendar.exams.entity.StudentGroup; import java.util.List; import java.util.Set; diff --git a/src/main/java/org/pkwmtt/calendar/exams/mapper/GroupMapper.java b/src/main/java/org/pkwmtt/calendar/exams/mapper/GroupMapper.java new file mode 100644 index 0000000..286b22d --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/exams/mapper/GroupMapper.java @@ -0,0 +1,39 @@ +package org.pkwmtt.calendar.exams.mapper; + +import org.pkwmtt.exceptions.InvalidGroupIdentifierException; + +import java.util.Set; +import java.util.stream.Collectors; + +public class GroupMapper { + private GroupMapper() {} + + /** + * extract superior group form general group e.g. 12K2 -> 12K + * @param generalGroup group for transformation + * @return superior group + */ + public static String trimLastDigit(String generalGroup) { + char lastChar = generalGroup.charAt(generalGroup.length() - 1); + if (Character.isDigit(lastChar)) + generalGroup = generalGroup.substring(0, generalGroup.length() - 1); + return generalGroup; + } + + /** + * extract common superior group form provided general groups e.g. 12K2 -> 12K + * @param superiorGroups set of general groups from the same year of study + * @return single superior group of provided general groups + * @throws InvalidGroupIdentifierException when not all provided groups belong to the same year of study + */ + public static String extractSuperiorGroup(Set superiorGroups) throws InvalidGroupIdentifierException { + if(superiorGroups == null || superiorGroups.isEmpty()) + throw new InvalidGroupIdentifierException("general group is missing"); + Set trimmedGroups = superiorGroups.stream() + .map(GroupMapper::trimLastDigit) + .collect(Collectors.toSet()); + if(trimmedGroups.size() > 1) + throw new InvalidGroupIdentifierException("ambiguous general groups for subgroups"); + return trimmedGroups.iterator().next(); + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/calendar/exams/repository/ExamGroupRepository.java b/src/main/java/org/pkwmtt/calendar/exams/repository/ExamGroupRepository.java new file mode 100644 index 0000000..1582733 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/exams/repository/ExamGroupRepository.java @@ -0,0 +1,8 @@ +package org.pkwmtt.calendar.exams.repository; + +import org.pkwmtt.calendar.exams.entity.ExamGroup; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ExamGroupRepository extends JpaRepository { +} + diff --git a/src/main/java/org/pkwmtt/examCalendar/repository/ExamRepository.java b/src/main/java/org/pkwmtt/calendar/exams/repository/ExamRepository.java similarity index 96% rename from src/main/java/org/pkwmtt/examCalendar/repository/ExamRepository.java rename to src/main/java/org/pkwmtt/calendar/exams/repository/ExamRepository.java index e89d873..116e39a 100644 --- a/src/main/java/org/pkwmtt/examCalendar/repository/ExamRepository.java +++ b/src/main/java/org/pkwmtt/calendar/exams/repository/ExamRepository.java @@ -1,6 +1,6 @@ -package org.pkwmtt.examCalendar.repository; +package org.pkwmtt.calendar.exams.repository; -import org.pkwmtt.examCalendar.entity.Exam; +import org.pkwmtt.calendar.exams.entity.Exam; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; diff --git a/src/main/java/org/pkwmtt/examCalendar/repository/ExamTypeRepository.java b/src/main/java/org/pkwmtt/calendar/exams/repository/ExamTypeRepository.java similarity index 69% rename from src/main/java/org/pkwmtt/examCalendar/repository/ExamTypeRepository.java rename to src/main/java/org/pkwmtt/calendar/exams/repository/ExamTypeRepository.java index c14d733..c55fe89 100644 --- a/src/main/java/org/pkwmtt/examCalendar/repository/ExamTypeRepository.java +++ b/src/main/java/org/pkwmtt/calendar/exams/repository/ExamTypeRepository.java @@ -1,6 +1,6 @@ -package org.pkwmtt.examCalendar.repository; +package org.pkwmtt.calendar.exams.repository; -import org.pkwmtt.examCalendar.entity.ExamType; +import org.pkwmtt.calendar.exams.entity.ExamType; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/src/main/java/org/pkwmtt/examCalendar/repository/GroupRepository.java b/src/main/java/org/pkwmtt/calendar/exams/repository/GroupRepository.java similarity index 69% rename from src/main/java/org/pkwmtt/examCalendar/repository/GroupRepository.java rename to src/main/java/org/pkwmtt/calendar/exams/repository/GroupRepository.java index 7a9e4dd..dc5a741 100644 --- a/src/main/java/org/pkwmtt/examCalendar/repository/GroupRepository.java +++ b/src/main/java/org/pkwmtt/calendar/exams/repository/GroupRepository.java @@ -1,6 +1,6 @@ -package org.pkwmtt.examCalendar.repository; +package org.pkwmtt.calendar.exams.repository; -import org.pkwmtt.examCalendar.entity.StudentGroup; +import org.pkwmtt.calendar.exams.entity.StudentGroup; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Set; diff --git a/src/main/java/org/pkwmtt/calendar/exams/repository/RepresentativeRepository.java b/src/main/java/org/pkwmtt/calendar/exams/repository/RepresentativeRepository.java new file mode 100644 index 0000000..e74f1e8 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/exams/repository/RepresentativeRepository.java @@ -0,0 +1,21 @@ +package org.pkwmtt.calendar.exams.repository; + +import jakarta.transaction.Transactional; +import org.pkwmtt.calendar.exams.entity.Representative; +import org.pkwmtt.calendar.enities.SuperiorGroup; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; + +import java.util.Optional; +import java.util.UUID; + +public interface RepresentativeRepository extends JpaRepository { + Optional findByEmail (String email); + + Optional findBySuperiorGroup (SuperiorGroup superiorGroup); + + @Modifying + @Transactional + void deleteRepresentativeByEmail (String email); +} + diff --git a/src/main/java/org/pkwmtt/calendar/exams/repository/SuperiorGroupRepository.java b/src/main/java/org/pkwmtt/calendar/exams/repository/SuperiorGroupRepository.java new file mode 100644 index 0000000..d58ccdf --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/exams/repository/SuperiorGroupRepository.java @@ -0,0 +1,10 @@ +package org.pkwmtt.calendar.exams.repository; + +import org.pkwmtt.calendar.enities.SuperiorGroup; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SuperiorGroupRepository extends JpaRepository { + Optional findByName(String name); +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/calendar/exams/services/ExamService.java b/src/main/java/org/pkwmtt/calendar/exams/services/ExamService.java new file mode 100644 index 0000000..fb0a814 --- /dev/null +++ b/src/main/java/org/pkwmtt/calendar/exams/services/ExamService.java @@ -0,0 +1,263 @@ +package org.pkwmtt.calendar.exams.services; + +import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.apache.logging.log4j.util.InternalException; +import org.pkwmtt.calendar.exams.dto.RequestExamDto; +import org.pkwmtt.calendar.exams.entity.Exam; +import org.pkwmtt.calendar.exams.entity.ExamType; +import org.pkwmtt.calendar.exams.entity.StudentGroup; +import org.pkwmtt.calendar.exams.mapper.ExamDtoMapper; +import org.pkwmtt.calendar.exams.repository.ExamRepository; +import org.pkwmtt.calendar.exams.repository.ExamTypeRepository; +import org.pkwmtt.calendar.exams.repository.GroupRepository; +import org.pkwmtt.exceptions.*; +import org.pkwmtt.timetable.TimetableService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.pkwmtt.calendar.exams.mapper.GroupMapper.extractSuperiorGroup; +import static org.pkwmtt.calendar.exams.mapper.GroupMapper.trimLastDigit; + +@Service +@RequiredArgsConstructor +@Transactional +public class ExamService { + + private final ExamRepository examRepository; + private final ExamTypeRepository examTypeRepository; + private final GroupRepository groupRepository; + private final TimetableService timetableService; + + /** + * @param requestExamDto details of exam + * @return id of exam added to database + */ + @PreAuthorize("@preAuthorizationService.verifyGroupPermissionsForNewResource(#requestExamDto.generalGroups)") + public int addExam (RequestExamDto requestExamDto) { + + Set groups = verifyAndUpdateExamGroups(requestExamDto); + + ExamType examType = examTypeRepository.findByName(requestExamDto.getExamType()) + .orElseThrow(() -> new ExamTypeNotExistsException(requestExamDto.getExamType())); + + Exam exam = ExamDtoMapper.mapToNewExam(requestExamDto, groups, examType); + Set existingExam = examRepository.findAllByTitle(exam.getTitle()); + + if (existingExam.contains(exam)) { + throw new ResourceAlreadyExistsException("Exam already exists"); + } + return examRepository.save(exam).getExamId(); + } + + /** + * @param requestExamDto new details of exam that overwrite old ones + * @param id of exam that need to be modified + */ + @PreAuthorize("@preAuthorizationService.verifyGroupPermissionsForModifiedResource(#requestExamDto.generalGroups, #id)") + public void modifyExam (RequestExamDto requestExamDto, int id) { + + Set groups = verifyAndUpdateExamGroups(requestExamDto); + + ExamType examType = examTypeRepository.findByName(requestExamDto.getExamType()) + .orElseThrow(() -> new ExamTypeNotExistsException(requestExamDto.getExamType())); + + examRepository.save(ExamDtoMapper.mapToExistingExam(requestExamDto, groups, examType, id)); + } + + /** + * @param id of exam + */ + @PreAuthorize("@preAuthorizationService.verifyGroupPermissionsForExistingResource(#id)") + public void deleteExam (int id) { + examRepository.deleteById(id); + } + + /** + * @param id of exam + * @return exam + */ + public Exam getExamById (int id) { + return examRepository.findById(id).orElseThrow(() -> new NoSuchElementWithProvidedIdException(id)); + } + + /** + * @param generalGroups set of general groups from the same year of study + * e.g. 12K1, 12K2 are from 12K year of study, + * but 12A1 and 12B1 or 11A1 and 12A1 aren't from the same year + * @param subgroups subgroups that belong to provided general groups + * @return set of exams containing provided groups + */ + public Set getExamByGroups (Set generalGroups, Set subgroups) { + + String superiorGroup = extractSuperiorGroup(generalGroups); + verifyGeneralGroupsFormat(generalGroups); + + if (subgroups == null || subgroups.isEmpty()) { + return examRepository.findAllByGroups_NameIn(generalGroups); + } + + verifySubgroupsFormat(subgroups); + return examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + superiorGroup, generalGroups, subgroups); + } + + /** + * @return list of examTypes + */ + public List getExamTypes () { + return examTypeRepository.findAll(); + } + + + /** + * verify if groups exists and updates database when it exists, but repository doesn't contain it. + * When timetable service is unavailable verifies groups using groupsRepository + * + * @param requestExamDto containing groups for verification + * @return single set of all kinds of provided groups as StudentGroup entities + * that are in database and could be safely attach to Exam entity + */ + private Set verifyAndUpdateExamGroups (RequestExamDto requestExamDto) { + Set generalGroups = requestExamDto.getGeneralGroups(); + Set subgroups = requestExamDto.getSubgroups(); + + if (generalGroups == null || generalGroups.isEmpty()) { + throw new InvalidGroupIdentifierException("general group is missing"); + } + + verifyGeneralGroups(generalGroups); + + if (subgroups == null || subgroups.isEmpty()) { + return saveNewStudentGroups(generalGroups); + } + + String superiorGroup = extractSuperiorGroup(generalGroups); + + + verifySubgroups(generalGroups, subgroups); + + subgroups.add(trimLastDigit(superiorGroup)); + return saveNewStudentGroups(subgroups); + } + + /** + * verifies provided generalGroups using timetable service or repository when service is unavailable + * + * @param generalGroups that would be verified + */ + private void verifyGeneralGroups (Set generalGroups) { + try { + Set existingGeneralGroups = new HashSet<>(timetableService.getGeneralGroupList()); + if (!existingGeneralGroups.containsAll(generalGroups)) { + throw new InvalidGroupIdentifierException(existingGeneralGroups, generalGroups); + } + } catch (WebPageContentNotAvailableException e) { + verifyGeneralGroupsUsingRepository(generalGroups); + } catch (JsonProcessingException e) { + throw new InternalException(e); + } + } + + /** + * @param groups that would be verified using repository + * @throws ServiceNotAvailableException when verification not succeeded + */ + private void verifyGeneralGroupsUsingRepository (Set groups) throws ServiceNotAvailableException { + verifyGeneralGroupsFormat(groups); + Set groupsFromRepository = groupRepository.findAllByNameIn(groups).stream() + .map(StudentGroup::getName) + .collect(Collectors.toSet() + ); + if (!groupsFromRepository.containsAll(groups)) { + throw new ServiceNotAvailableException( + "Timetable service unavailable, couldn't verify groups using repository"); + } + } + + + private void verifySubgroups (Set generalGroups, Set subgroups) { + try { + Set subGroupsFromTimetable = new HashSet<>(); + for (String generalGroup : generalGroups) { + subGroupsFromTimetable.addAll(timetableService.getAvailableSubGroups(generalGroup)); + } + if (!subGroupsFromTimetable.containsAll(subgroups)) { + throw new InvalidGroupIdentifierException(subGroupsFromTimetable, subgroups); + } + } catch (JsonProcessingException | + SpecifiedGeneralGroupDoesntExistsException | + WebPageContentNotAvailableException e) { + verifySubgroupsUsingRepository(extractSuperiorGroup(generalGroups), subgroups); + } + } + + /** + * @param generalGroup of provided subgroups + * @param groups subgroups for verification + * @throws ServiceNotAvailableException when verification not succeeded + */ + private void verifySubgroupsUsingRepository (String generalGroup, Set groups) + throws ServiceNotAvailableException { + groups.add(generalGroup); + if (examRepository.findCommonExamIdsForGroups(groups, groups.size()).isEmpty()) { + throw new ServiceNotAvailableException( + "Timetable service unavailable, 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 Entities with provided names + */ + 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; + } + + /** + * @param generalGroups general groups for verification + * @throws SpecifiedGeneralGroupDoesntExistsException when format is invalid + */ + private static void verifyGeneralGroupsFormat (Set generalGroups) + throws SpecifiedGeneralGroupDoesntExistsException { + generalGroups.forEach(group -> { + if (!group.matches("^\\d.*")) { + throw new SpecifiedGeneralGroupDoesntExistsException(group); + } + }); + } + + /** + * @param subgroups subgroups for verification + * @throws SpecifiedSubGroupDoesntExistsException when format is invalid + */ + private static void verifySubgroupsFormat (Set subgroups) + throws SpecifiedSubGroupDoesntExistsException { + subgroups.forEach(group -> { + if (!group.matches("^[A-Z].*")) { + throw new SpecifiedSubGroupDoesntExistsException(group); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/examCalendar/ExamService.java b/src/main/java/org/pkwmtt/examCalendar/ExamService.java deleted file mode 100644 index 1e39bdb..0000000 --- a/src/main/java/org/pkwmtt/examCalendar/ExamService.java +++ /dev/null @@ -1,321 +0,0 @@ -package org.pkwmtt.examCalendar; - -import com.fasterxml.jackson.core.JsonProcessingException; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.pkwmtt.examCalendar.dto.RequestExamDto; -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.security.token.JwtAuthenticationToken; -import org.pkwmtt.timetable.TimetableService; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; -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 requestExamDto details of exam - * @return id of exam added to database - */ - public int addExam(RequestExamDto requestExamDto) { - - verifyGroupPermissionsForNewResource(requestExamDto.getGeneralGroups()); - - Set groups = verifyAndUpdateExamGroups(requestExamDto); - - ExamType examType = examTypeRepository.findByName(requestExamDto.getExamType()) - .orElseThrow(() -> new ExamTypeNotExistsException(requestExamDto.getExamType())); - - Exam exam = ExamDtoMapper.mapToNewExam(requestExamDto, groups, examType); - Set existingExam = examRepository.findAllByTitle(exam.getTitle()); - - if (existingExam.contains(exam)) - throw new ResourceAlreadyExistsException("Exam already exists"); - return examRepository.save(exam).getExamId(); - } - - /** - * @param requestExamDto new details of exam that overwrite old ones - * @param id of exam that need to be modified - */ - public void modifyExam(RequestExamDto requestExamDto, int id) { - - examRepository.findById(id).orElseThrow(() -> new NoSuchElementWithProvidedIdException(id)); - - verifyGroupPermissionsForModifiedResource(requestExamDto.getGeneralGroups(), id); - - Set groups = verifyAndUpdateExamGroups(requestExamDto); - - ExamType examType = examTypeRepository.findByName(requestExamDto.getExamType()) - .orElseThrow(() -> new ExamTypeNotExistsException(requestExamDto.getExamType())); - - examRepository.save(ExamDtoMapper.mapToExistingExam(requestExamDto, groups, examType, id)); - } - - /** - * @param id of exam - */ - public void deleteExam(int id) { - examRepository.findById(id).orElseThrow(() -> new NoSuchElementWithProvidedIdException(id)); - verifyGroupPermissionsForExistingResource(id); - examRepository.deleteById(id); - } - - /** - * @param id of exam - * @return exam - */ - public Exam getExamById(int id) { - return examRepository.findById(id).orElseThrow(() -> new NoSuchElementWithProvidedIdException(id)); - } - - /** - * @param generalGroups set of general groups from the same year of study - * e.g. 12K1, 12K2 are from 12K year of study, - * but 12A1 and 12B1 or 11A1 and 12A1 aren't from the same year - * @param subgroups subgroups that belong to provided general groups - * @return set of exams containing provided groups - */ - public Set getExamByGroups(Set generalGroups, Set subgroups) { - - String superiorGroup = extractSuperiorGroup(generalGroups); - verifyGeneralGroupsFormat(generalGroups); - - if(subgroups == null || subgroups.isEmpty()) - return examRepository.findAllByGroups_NameIn(generalGroups); - - verifySubgroupsFormat(subgroups); - return examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup(superiorGroup, generalGroups, subgroups); - } - - /** - * @return list of examTypes - */ - public List getExamTypes() { - return examTypeRepository.findAll(); - } - - - /** - * verify if groups exists and updates database when it exists, but repository doesn't contain it. - * When timetable service is unavailable verifies groups using groupsRepository - * @param requestExamDto containing groups for verification - * @return single set of all kinds of provided groups as StudentGroup entities - * that are in database and could be safely attach to Exam entity - */ - private Set verifyAndUpdateExamGroups(RequestExamDto requestExamDto) { - Set generalGroups = requestExamDto.getGeneralGroups(); - Set subgroups = requestExamDto.getSubgroups(); - - if (generalGroups == null || generalGroups.isEmpty()) - throw new InvalidGroupIdentifierException("general group is missing"); - - verifyGeneralGroups(generalGroups); - - if (subgroups == null || subgroups.isEmpty()) - return saveNewStudentGroups(generalGroups); - - String superiorGroup = extractSuperiorGroup(generalGroups); - - - verifySubgroups(generalGroups, subgroups); - - subgroups.add(trimLastDigit(superiorGroup)); - return saveNewStudentGroups(subgroups); - } - - /** - * verifies provided generalGroups using timetable service or repository when service is unavailable - * @param generalGroups that would be verified - */ - private void verifyGeneralGroups(Set generalGroups) { - try { - Set existingGeneralGroups = new HashSet<>(timetableService.getGeneralGroupList()); - if (!existingGeneralGroups.containsAll(generalGroups)) - throw new InvalidGroupIdentifierException(existingGeneralGroups, generalGroups); - } catch (WebPageContentNotAvailableException e) { - verifyGeneralGroupsUsingRepository(generalGroups); - } - } - - /** - * @param groups that would be verified using repository - * @throws ServiceNotAvailableException when verification not succeeded - */ - private void verifyGeneralGroupsUsingRepository(Set groups) throws ServiceNotAvailableException { - verifyGeneralGroupsFormat(groups); - Set groupsFromRepository = groupRepository.findAllByNameIn(groups).stream() - .map(StudentGroup::getName) - .collect(Collectors.toSet() - ); - if (!groupsFromRepository.containsAll(groups)) - throw new ServiceNotAvailableException("Timetable service unavailable, couldn't verify groups using repository"); - } - - - private void verifySubgroups(Set generalGroups, Set subgroups){ - try { - Set subGroupsFromTimetable = new HashSet<>(); - for(String generalGroup : generalGroups){ - subGroupsFromTimetable.addAll(timetableService.getAvailableSubGroups(generalGroup)); - } - if (!subGroupsFromTimetable.containsAll(subgroups)) - throw new InvalidGroupIdentifierException(subGroupsFromTimetable, subgroups); - } catch (JsonProcessingException | - SpecifiedGeneralGroupDoesntExistsException | - WebPageContentNotAvailableException e) { - verifySubgroupsUsingRepository(extractSuperiorGroup(generalGroups), subgroups); - } - } - - /** - * @param generalGroup of provided subgroups - * @param groups subgroups for verification - * @throws ServiceNotAvailableException when verification not succeeded - */ - private void verifySubgroupsUsingRepository(String generalGroup, Set groups) throws ServiceNotAvailableException { - groups.add(generalGroup); - if(examRepository.findCommonExamIdsForGroups(groups, groups.size()).isEmpty()) - throw new ServiceNotAvailableException("Timetable service unavailable, couldn't verify groups using repository"); - } - - /** - * extract superior group form general group e.g. 12K2 -> 12K - * @param generalGroup group for transformation - * @return superior group - */ - private static String trimLastDigit(String generalGroup) { - char lastChar = generalGroup.charAt(generalGroup.length() - 1); - if (Character.isDigit(lastChar)) - generalGroup = generalGroup.substring(0, generalGroup.length() - 1); - return generalGroup; - } - - /** - * extract common superior group form provided general groups e.g. 12K2 -> 12K - * @param generalGroup set of general groups from the same year of study - * @return single superior group of provided general groups - * @throws InvalidGroupIdentifierException when not all provided groups belong to the same year of study - */ - private static String extractSuperiorGroup(Set generalGroup) throws InvalidGroupIdentifierException { - if(generalGroup == null || generalGroup.isEmpty()) - throw new InvalidGroupIdentifierException("general group is missing"); - Set trimmedGroups = generalGroup.stream() - .map(ExamService::trimLastDigit) - .collect(Collectors.toSet()); - if(trimmedGroups.size() > 1) - throw new InvalidGroupIdentifierException("ambiguous general groups for subgroups"); - return trimmedGroups.iterator().next(); - } - - /** - * 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 Entities with provided names - */ - 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; - } - - /** - * @param generalGroups general groups for verification - * @throws SpecifiedGeneralGroupDoesntExistsException when format is invalid - */ - private static void verifyGeneralGroupsFormat(Set generalGroups) throws SpecifiedGeneralGroupDoesntExistsException { - generalGroups.forEach(group -> { - if (!group.matches("^\\d.*")) - throw new SpecifiedGeneralGroupDoesntExistsException(group); - }); - } - - /** - * @param subgroups subgroups for verification - * @throws SpecifiedSubGroupDoesntExistsException when format is invalid - */ - private static void verifySubgroupsFormat(Set subgroups) throws SpecifiedSubGroupDoesntExistsException { - subgroups.forEach(group -> { - if (!group.matches("^[A-Z].*")) - throw new SpecifiedSubGroupDoesntExistsException(group); - }); - } - - /** - * verifies if user has authorities to add new resource - * @param newGroups set of provided groups - */ - private void verifyGroupPermissionsForNewResource(Set newGroups){ - String userGroup = getUserGroup(); - Set groupsFromRequest = new HashSet<>(newGroups); - if(!extractSuperiorGroup(groupsFromRequest).equals(userGroup)) - throw new AccessDeniedException("You don't have permission to access this group"); - } - - /** - * verifies if user has authorities to modify existing resource - * @param examId id of existing resource - */ - private void verifyGroupPermissionsForExistingResource(Integer examId){ - String userGroup = getUserGroup(); - Set generalGroupsOfExam = examRepository.findGroupsByExamId(examId) - .stream() - .filter(group -> group.matches("^\\d.*")) - .collect(Collectors.toSet()); - if(!extractSuperiorGroup(generalGroupsOfExam).equals(userGroup)) - throw new AccessDeniedException("You don't have permission to access this group"); - } - - /** - * verifies if user had authorities to replace existing resource with new one - * @param newGroups set of groups of new resource - * @param examId id of existing resource - */ - private void verifyGroupPermissionsForModifiedResource(Set newGroups, Integer examId){ - verifyGroupPermissionsForNewResource(newGroups); - verifyGroupPermissionsForExistingResource(examId); - } - - /** - * @return superior group identifier (e.g. 12K) of currently authenticated user - * @throws AccessDeniedException when user doesn't have assigned group - */ - private String getUserGroup() throws AccessDeniedException { - JwtAuthenticationToken authentication = (JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); - String group = authentication.getExamGroup(); - if(group == null) - throw new AccessDeniedException("You doesn't have access to any group"); - return group; - } -} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/examCalendar/entity/OTPCode.java b/src/main/java/org/pkwmtt/examCalendar/entity/OTPCode.java deleted file mode 100644 index 2694908..0000000 --- a/src/main/java/org/pkwmtt/examCalendar/entity/OTPCode.java +++ /dev/null @@ -1,38 +0,0 @@ -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/User.java b/src/main/java/org/pkwmtt/examCalendar/entity/User.java deleted file mode 100644 index 4cdfbc9..0000000 --- a/src/main/java/org/pkwmtt/examCalendar/entity/User.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.pkwmtt.examCalendar.entity; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.pkwmtt.examCalendar.enums.Role; - -@Entity -@Getter -@Builder -@AllArgsConstructor -@NoArgsConstructor -@Table(name = "`users`") -public class User { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "user_id") - private Integer userId; - - @OneToOne - @JoinColumn(name = "general_group_id", nullable = false) - private GeneralGroup generalGroup; - - @Column(nullable = false) - private String email; - - @Column(name = "is_active", nullable = false) - private boolean isActive; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private Role role; -} diff --git a/src/main/java/org/pkwmtt/examCalendar/enums/Role.java b/src/main/java/org/pkwmtt/examCalendar/enums/Role.java deleted file mode 100644 index aafdf12..0000000 --- a/src/main/java/org/pkwmtt/examCalendar/enums/Role.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.pkwmtt.examCalendar.enums; - -public enum Role { - ADMIN, - REPRESENTATIVE -} \ 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 deleted file mode 100644 index fa787aa..0000000 --- a/src/main/java/org/pkwmtt/examCalendar/repository/GeneralGroupRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -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/UserRepository.java b/src/main/java/org/pkwmtt/examCalendar/repository/UserRepository.java deleted file mode 100644 index 6add845..0000000 --- a/src/main/java/org/pkwmtt/examCalendar/repository/UserRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.pkwmtt.examCalendar.repository; - -import jakarta.transaction.Transactional; -import org.pkwmtt.examCalendar.entity.GeneralGroup; -import org.pkwmtt.examCalendar.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - Optional findByEmail (String email); - - Optional findByGeneralGroup (GeneralGroup generalGroup); - - @Query("SELECT g.name FROM User u LEFT JOIN u.generalGroup g where u.email = :email") - - @Transactional - void deleteUserByEmail (String email); - -} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/exceptions/CacheContentNotAvailableException.java b/src/main/java/org/pkwmtt/exceptions/CacheContentNotAvailableException.java new file mode 100644 index 0000000..003cd0c --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/CacheContentNotAvailableException.java @@ -0,0 +1,7 @@ +package org.pkwmtt.exceptions; + +public class CacheContentNotAvailableException extends RuntimeException { + public CacheContentNotAvailableException (String message) { + super(message); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/InvalidRefreshTokenException.java b/src/main/java/org/pkwmtt/exceptions/InvalidRefreshTokenException.java new file mode 100644 index 0000000..dcc6d99 --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/InvalidRefreshTokenException.java @@ -0,0 +1,7 @@ +package org.pkwmtt.exceptions; + +public class InvalidRefreshTokenException extends RuntimeException { + public InvalidRefreshTokenException() { + super("Invalid refresh token"); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/MaxUsageForStudentCodeReachedException.java b/src/main/java/org/pkwmtt/exceptions/MaxUsageForStudentCodeReachedException.java new file mode 100644 index 0000000..c86bb19 --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/MaxUsageForStudentCodeReachedException.java @@ -0,0 +1,8 @@ +package org.pkwmtt.exceptions; + +public class MaxUsageForStudentCodeReachedException extends Exception { + public MaxUsageForStudentCodeReachedException(String message) { + super(message); + } + +} diff --git a/src/main/java/org/pkwmtt/exceptions/OTPCodeNotFoundException.java b/src/main/java/org/pkwmtt/exceptions/OTPCodeNotFoundException.java deleted file mode 100644 index 2626ec8..0000000 --- a/src/main/java/org/pkwmtt/exceptions/OTPCodeNotFoundException.java +++ /dev/null @@ -1,8 +0,0 @@ -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/StudentCodeNotFoundException.java b/src/main/java/org/pkwmtt/exceptions/StudentCodeNotFoundException.java new file mode 100644 index 0000000..3f81a04 --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/StudentCodeNotFoundException.java @@ -0,0 +1,8 @@ +package org.pkwmtt.exceptions; + +public class StudentCodeNotFoundException + extends RuntimeException { + public StudentCodeNotFoundException () { + super("Student code not found."); + } +} diff --git a/src/main/java/org/pkwmtt/exceptions/WrongOTPFormatException.java b/src/main/java/org/pkwmtt/exceptions/WrongOTPFormatException.java deleted file mode 100644 index 414d347..0000000 --- a/src/main/java/org/pkwmtt/exceptions/WrongOTPFormatException.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.pkwmtt.exceptions; - -public class WrongOTPFormatException - extends IllegalArgumentException { - public WrongOTPFormatException (String message) { - super(message); - } -} diff --git a/src/main/java/org/pkwmtt/exceptions/WrongStudentCodeFormatException.java b/src/main/java/org/pkwmtt/exceptions/WrongStudentCodeFormatException.java new file mode 100644 index 0000000..527c1bf --- /dev/null +++ b/src/main/java/org/pkwmtt/exceptions/WrongStudentCodeFormatException.java @@ -0,0 +1,8 @@ +package org.pkwmtt.exceptions; + +public class WrongStudentCodeFormatException + extends IllegalArgumentException { + public WrongStudentCodeFormatException (String message) { + super(message); + } +} diff --git a/src/main/java/org/pkwmtt/files/FileController.java b/src/main/java/org/pkwmtt/files/FileController.java deleted file mode 100644 index 8a6084b..0000000 --- a/src/main/java/org/pkwmtt/files/FileController.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.pkwmtt.files; - -import lombok.RequiredArgsConstructor; -import org.springframework.core.io.UrlResource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.io.FileNotFoundException; -import java.io.IOException; - -@RestController -@RequestMapping("/admin/files") -@RequiredArgsConstructor -public class FileController { - private final FileService service; - - /** - * @param file provided file - * @return 200 if request ok - * @throws IOException when file or directory malformed - */ - @PostMapping(value = "/upload", consumes = MediaType.ALL_VALUE) - public ResponseEntity upload (@RequestParam("file") MultipartFile file) throws IOException { - service.upload(file); - return ResponseEntity.ok().build(); - } - - /** - * @param fileName name of requested file - * @return file - * @throws IOException problem with accessing selected file - */ - @GetMapping(value = "/download/{fileName}") - public ResponseEntity download (@PathVariable String fileName) throws IOException { - try { - UrlResource resource = service.getResourceByFileName(fileName); - return ResponseEntity - .ok() - .contentType(service.getContentNameByFileName(fileName)) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"") - .body(resource); - } catch (FileNotFoundException e) { - return ResponseEntity.notFound().build(); - } - } -} diff --git a/src/main/java/org/pkwmtt/files/FileService.java b/src/main/java/org/pkwmtt/files/FileService.java deleted file mode 100644 index fb3a30c..0000000 --- a/src/main/java/org/pkwmtt/files/FileService.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.pkwmtt.files; - -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.UrlResource; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Objects; - -@Service -@RequiredArgsConstructor -public class FileService { - @Value("${app.upload.dir:uploads}") - private String UPLOADS_DIR; - - /** - * Upload files - admin only - * - * @param file - file to upload - * @throws IOException - when location is malformed - */ - public void upload (MultipartFile file) throws IOException { - Path projectRoot = Paths.get("").toAbsolutePath(); - Path uploadPath = projectRoot.resolve(UPLOADS_DIR); - - //Create directory if not exists - if (!Files.exists(uploadPath)) { - Files.createDirectories(uploadPath); - } - - //Create file - Path filePath = uploadPath.resolve(Objects.requireNonNull(file.getOriginalFilename())); - - //Move content from provided file to recently created one - file.transferTo(filePath.toFile()); - } - - public UrlResource getResourceByFileName (String fileName) throws IOException { - //Dir: ProjectRoot/uploads/fileName - Path filePath = getFilePathByName(fileName); - UrlResource resource = new UrlResource(filePath.toUri()); - - if (!resource.exists()) { - throw new FileNotFoundException(); - } - return resource; - } - - public MediaType getContentNameByFileName (String fileName) throws IOException { - Path filePath = getFilePathByName(fileName); - - //Get file content - String contentType = Files.probeContentType(filePath); - if (contentType == null) { - //Default value - contentType = "application/octet-stream"; - } - //Parse to Media type - return MediaType.parseMediaType(contentType); - } - - private Path getFilePathByName (String fileName) { - //Location of provided file - return Paths.get("").toAbsolutePath().resolve(UPLOADS_DIR).resolve(fileName).normalize(); - } -} diff --git a/src/main/java/org/pkwmtt/files/FileUploadsExceptionHandler.java b/src/main/java/org/pkwmtt/files/FileUploadsExceptionHandler.java deleted file mode 100644 index 3e7f71f..0000000 --- a/src/main/java/org/pkwmtt/files/FileUploadsExceptionHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.pkwmtt.files; - -import org.pkwmtt.exceptions.dto.ErrorResponseDTO; -import org.pkwmtt.files.apk.ApkController; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.io.IOException; - -@RestControllerAdvice(assignableTypes = {FileController.class, ApkController.class}) -public class FileUploadsExceptionHandler { - - @ExceptionHandler(IOException.class) - public ResponseEntity handleIOException () { - return new ResponseEntity<>( - new ErrorResponseDTO("File or directory not found or is malformed."), - HttpStatus.NOT_FOUND - ); - } - - @ExceptionHandler({IllegalAccessException.class, RuntimeException.class}) - public ResponseEntity handleIllegalArgumentException (Exception e) { - return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.BAD_REQUEST); - } -} diff --git a/src/main/java/org/pkwmtt/files/apk/ApkController.java b/src/main/java/org/pkwmtt/files/apk/ApkController.java deleted file mode 100644 index 2acf6da..0000000 --- a/src/main/java/org/pkwmtt/files/apk/ApkController.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.pkwmtt.files.apk; - -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.core.io.UrlResource; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -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; - -import java.io.IOException; -import java.util.List; - -@RequestMapping("${apiPrefix}/apk") -@RestController -@RequiredArgsConstructor -public class ApkController { - - private final ApkService apkService; - - @GetMapping("/download") - public ResponseEntity download (HttpServletRequest request) throws IOException { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.parseMediaType("application/vnd.android.package-archive")); - headers.setContentDisposition(ContentDisposition.attachment().filename("PKWM_App.apk").build()); - - - String origin = request.getHeader("Origin"); - - if (origin == null || origin.isBlank()) { - return ResponseEntity.ok().headers(headers).body(apkService.getApkResource()); - } - - List allowedOrigins = List.of("https://pkwmapp.pl", "http://localhost:3000"); - if (allowedOrigins.contains(origin)) { - headers.set("Access-Control-Allow-Origin", origin); - } - return ResponseEntity.ok().headers(headers).body(apkService.getApkResource()); - } - - @GetMapping("/version") - public String getApkVersion () throws IOException { - return apkService.getApkVersion(); - } -} diff --git a/src/main/java/org/pkwmtt/files/apk/ApkService.java b/src/main/java/org/pkwmtt/files/apk/ApkService.java deleted file mode 100644 index bc72f22..0000000 --- a/src/main/java/org/pkwmtt/files/apk/ApkService.java +++ /dev/null @@ -1,66 +0,0 @@ -package org.pkwmtt.files.apk; - -import lombok.RequiredArgsConstructor; -import org.pkwmtt.files.FileService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.UrlResource; -import org.springframework.stereotype.Service; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Comparator; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Stream; - -@Service -@RequiredArgsConstructor -public class ApkService { - @Value("${app.upload.dir:uploads}") - private String FILES_DIR; - - private final FileService fileService; - - public UrlResource getApkResource () throws IOException, IllegalArgumentException { - Path filePath = findNewestApkByExtensionInUploads().orElseThrow(FileNotFoundException::new); - return fileService.getResourceByFileName(filePath.getFileName().toString()); - } - - public String getApkVersion () throws IOException { - Path filePath = findNewestApkByExtensionInUploads().orElseThrow(IOException::new); - String fileName = filePath.getFileName().toString(); - Pattern pattern = Pattern.compile("\\d+(?:\\.\\d+){1,2}"); - Matcher matcher = pattern.matcher(fileName); - if (!matcher.find()) { - return null; - } - return matcher.group(); - } - - private Optional findNewestApkByExtensionInUploads () throws IOException, IllegalArgumentException { - Path dirPath = Paths.get(FILES_DIR); - - if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { - throw new IllegalArgumentException("Invalid directory: " + dirPath); - } - - Stream stream = Files.list(dirPath); - - try (stream) { - return stream - .filter(Files::isRegularFile) - .filter(file -> file.getFileName().toString().toLowerCase().endsWith(".apk")) - .max(Comparator.comparingLong(file -> { - try { - return Files.getLastModifiedTime(file).toMillis(); - } catch (IOException e) { - throw new RuntimeException("Couldn't locate last modified file"); - } - })); - } - } -} diff --git a/src/main/java/org/pkwmtt/global/GlobalExceptionHandler.java b/src/main/java/org/pkwmtt/global/GlobalExceptionHandler.java index 5438a5e..408f8ee 100644 --- a/src/main/java/org/pkwmtt/global/GlobalExceptionHandler.java +++ b/src/main/java/org/pkwmtt/global/GlobalExceptionHandler.java @@ -2,6 +2,8 @@ import org.apache.logging.log4j.util.InternalException; import org.pkwmtt.exceptions.IncorrectApiKeyValue; +import org.pkwmtt.exceptions.MaxUsageForStudentCodeReachedException; +import org.pkwmtt.exceptions.MissingHeaderException; import org.pkwmtt.exceptions.dto.ErrorResponseDTO; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -16,8 +18,18 @@ public ResponseEntity handleIncorrectApiKeyValue (Exception e) return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.UNAUTHORIZED); } + @ExceptionHandler(MissingHeaderException.class) + public ResponseEntity handleMissingHeaderException (Exception e) { + return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.BAD_REQUEST); + } + @ExceptionHandler(InternalException.class) public ResponseEntity handleInternalException (Exception e) { return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR); } + + @ExceptionHandler(MaxUsageForStudentCodeReachedException.class) + public ResponseEntity handleMaxUsageForStudentCodeReachedException (Exception e) { + return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.FORBIDDEN); + } } diff --git a/src/main/java/org/pkwmtt/global/RequestInterceptor.java b/src/main/java/org/pkwmtt/global/RequestInterceptor.java index 23d42c2..2e2c269 100644 --- a/src/main/java/org/pkwmtt/global/RequestInterceptor.java +++ b/src/main/java/org/pkwmtt/global/RequestInterceptor.java @@ -6,7 +6,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.logging.log4j.util.InternalException; -import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.calendar.exams.enums.Role; import org.pkwmtt.exceptions.IncorrectApiKeyValue; import org.pkwmtt.exceptions.MissingHeaderException; import org.pkwmtt.security.apiKey.ApiKeyService; @@ -14,30 +14,38 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +import static java.util.Objects.isNull; + @Slf4j @Component @RequiredArgsConstructor @Profile("!test & !database") //Skip on tests public class RequestInterceptor implements HandlerInterceptor { + private static final String X_API_KEY_HEADER = "X-API-KEY"; + private final ApiKeyService apiKeyService; @Override - public boolean preHandle (@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) { + public boolean preHandle (@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler) throws MissingHeaderException { + String apiKey = request.getHeader(X_API_KEY_HEADER); - String headerName = "X-API-KEY"; - try { - String providedApiKey = request.getHeader(headerName); - - if (providedApiKey == null || providedApiKey.isBlank()) { - throw new MissingHeaderException(headerName); + if (isNull(apiKey) || apiKey.isBlank()) { + apiKey = request.getHeader(X_API_KEY_HEADER.toLowerCase()); + if (isNull(apiKey) || apiKey.isBlank()) { + throw new MissingHeaderException("X-API-KEY"); } - - apiKeyService.validateApiKey(providedApiKey, Role.REPRESENTATIVE); - } catch (IncorrectApiKeyValue | MissingHeaderException e) { - throw new IncorrectApiKeyValue(); + } + + try { + apiKeyService.validateApiKey(apiKey, Role.REPRESENTATIVE); + } catch (IncorrectApiKeyValue e) { + // Rethrow specific validation error so it can be handled appropriately + throw e; } catch (Exception e) { - log.error(e.getMessage()); + log.error("Unexpected error during API key validation", e); throw new InternalException("Internal server error with validating API key."); } diff --git a/src/main/java/org/pkwmtt/global/config/HighlightingCompositeLogConverter.java b/src/main/java/org/pkwmtt/global/config/HighlightingCompositeLogConverter.java index 24bf38f..7859f1f 100644 --- a/src/main/java/org/pkwmtt/global/config/HighlightingCompositeLogConverter.java +++ b/src/main/java/org/pkwmtt/global/config/HighlightingCompositeLogConverter.java @@ -6,7 +6,7 @@ import ch.qos.logback.core.pattern.color.ForegroundCompositeConverterBase; public class HighlightingCompositeLogConverter extends ForegroundCompositeConverterBase { - + @Override protected String getForegroundColorCode(ILoggingEvent event) { return switch (event.getLevel().toInt()) { diff --git a/src/main/java/org/pkwmtt/global/config/LogDirectoryInitializer.java b/src/main/java/org/pkwmtt/global/config/LogDirectoryInitializer.java new file mode 100644 index 0000000..5bee617 --- /dev/null +++ b/src/main/java/org/pkwmtt/global/config/LogDirectoryInitializer.java @@ -0,0 +1,44 @@ +package org.pkwmtt.global.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +/** + * Ensures the log directory and app.log exist on application startup. + * Implemented as a Spring component so it runs early during context initialization. + */ +@Component +@SuppressWarnings("unused") +public class LogDirectoryInitializer { + + @PostConstruct + public void ensureLogFile () { + Path logsDir = Paths.get("logs"); + try { + // create directory if missing (no-op if exists) + Files.createDirectories(logsDir); + + Path appLog = logsDir.resolve("app.log"); + // Open with CREATE and APPEND so it is created atomically if missing and left intact otherwise + try ( + OutputStream os = Files.newOutputStream( + appLog, + StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND + ) + ) { + // ensure the stream is valid without writing data + os.flush(); + } + } catch (IOException e) { + // Avoid logging frameworks here because this runs during logging initialization + System.err.println("Could not ensure logs/app.log: " + e.getMessage()); + } + } +} diff --git a/src/main/java/org/pkwmtt/global/config/SwaggerEndpointConfiguration.java b/src/main/java/org/pkwmtt/global/config/SwaggerEndpointConfiguration.java deleted file mode 100644 index 6556243..0000000 --- a/src/main/java/org/pkwmtt/global/config/SwaggerEndpointConfiguration.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.pkwmtt.global.config; - - -import io.swagger.v3.oas.models.OpenAPI; -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 io.swagger.v3.oas.models.servers.Server; -import lombok.RequiredArgsConstructor; -import org.springdoc.core.models.GroupedOpenApi; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; - -import java.util.List; - -@Configuration -@RequiredArgsConstructor -public class SwaggerEndpointConfiguration { - - private final Environment environment; - - @Value("${swagger.url:http://localhost:8080}") - String url; - - @Bean - public OpenAPI setOpenApiProtocol () { - return new OpenAPI().servers(List.of(new Server().url(url))); - } - - - //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("/**").addOpenApiCustomizer(openApi -> { - Paths paths = openApi.getPaths(); - - paths.forEach((path, pathItem) -> pathItem.readOperationsMap().forEach(((httpMethod, operation) -> { - if (path.startsWith("/admin")) { - addHeaderIfMissing( - operation, - "X-ADMIN-KEY", - "Admin API key", - "Admin-only endpoint", - "Requires X-ADMIN-KEY header", - "admin", - true - ); - } else if (path.startsWith(apiPrefix)) { - addHeaderIfMissing( - operation, - "X-API-KEY", - "Your API key", - "Public API endpoint", - "Requires X-API-KEY header", - "public", - true - ); - } - }))); - }).build(); - } - - private void addHeaderIfMissing (Operation operation, String headerName, String headerDescription, String summary, String description, String tag, boolean required) { - operation.setSummary(summary); - operation.setDescription(description); - operation.addTagsItem(tag); - operation.addParametersItem(new Parameter() - .name(headerName) - .in("header") - .required(required) - .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 index fc5dc21..5135c0c 100644 --- a/src/main/java/org/pkwmtt/global/config/WebConfig.java +++ b/src/main/java/org/pkwmtt/global/config/WebConfig.java @@ -3,7 +3,7 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.pkwmtt.global.RequestInterceptor; -import org.pkwmtt.security.admin.AdminRequestInterceptor; +import org.pkwmtt.admin.AdminRequestInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; diff --git a/src/main/java/org/pkwmtt/mail/config/MailConfig.java b/src/main/java/org/pkwmtt/mail/config/MailConfig.java index 15a4680..79f4cb6 100644 --- a/src/main/java/org/pkwmtt/mail/config/MailConfig.java +++ b/src/main/java/org/pkwmtt/mail/config/MailConfig.java @@ -40,9 +40,11 @@ public JavaMailSender javaMailSender () { mailSender.setPassword(password); Properties props = mailSender.getJavaMailProperties(); - props.put("mail.transport.protocol", "smtp"); - props.put("mail.smtp.auth", "true"); - props.put("mail.smtp.starttls.enable", "true"); + props.put("mail.transport.protocol", environment.getProperty("spring.mail.protocol", "smtp")); + // Respect properties from configuration (allows tests to disable STARTTLS for GreenMail) + props.put("mail.smtp.auth", environment.getProperty("spring.mail.properties.mail.smtp.auth", "false")); + props.put("mail.smtp.starttls.enable", environment.getProperty("spring.mail.properties.mail.smtp.starttls.enable", "false")); + props.put("mail.smtp.ssl.enable", environment.getProperty("spring.mail.properties.mail.smtp.ssl.enable", "false")); return mailSender; } diff --git a/src/main/java/org/pkwmtt/moderator/MODERATOR.MD b/src/main/java/org/pkwmtt/moderator/MODERATOR.MD new file mode 100644 index 0000000..d6018dd --- /dev/null +++ b/src/main/java/org/pkwmtt/moderator/MODERATOR.MD @@ -0,0 +1,239 @@ +# Moderator Controller — API Reference + +This document explains the REST endpoints exposed by `ModeratorController`. + +Base URL: + +http://localhost:8080/pkwmtt/api/v1/moderator + +Summary / quick checklist +- Use `Accept: application/json` for all requests and `Content-Type: application/json` for requests with a body. +- Authentication endpoints return JWT tokens. Use the `accessToken` in `Authorization: Bearer ` for subsequent requests that require moderator privileges (where applicable). +- `POST /users` accepts a JSON array of `StudentCodeRequest` objects and will send codes by email. It returns 204 No Content on full success, 207 Multi-Status with failures when some emails failed, or 400 Bad Request for an empty body. + +## Endpoints + +### 1) POST `/authenticate` +- Path: +``` +POST /authenticate +Host: localhost:8080 +``` +- Request body: `AuthDto` JSON (username currently not used, password required) +- Produces: application/json +- Success: 200 OK with `JwtAuthenticationDto` JSON (contains `accessToken` and `refreshToken`). +- Errors: 4xx/5xx depending on authentication failures or server errors. + +Example request (HTTP-style): +``` +POST http://localhost:8080/pkwmtt/api/v1/moderator/authenticate +Content-Type: application/json +Accept: application/json +``` + +Request body (JSON): +```json +{ + "username": "moderator", + "password": "secret-password" +} +``` + +Curl example (Windows/cmd compatible): +``` +curl -v -X POST "http://localhost:8080/pkwmtt/api/v1/moderator/authenticate" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"moderator\",\"password\":\"secret-password\"}" +``` + + +### 2) POST `/refresh` +- Path: +``` +POST /refresh +Host: localhost:8080 +``` +- Request body: `RefreshRequestDto` JSON { "refreshToken": "..." } +- Success: 200 OK with `JwtAuthenticationDto` JSON (new tokens). + +Example request (HTTP-style): +``` +POST http://localhost:8080/pkwmtt/api/v1/moderator/refresh +Content-Type: application/json +Accept: application/json +``` + +Request body (JSON): +```json +{ + "refreshToken": "eyJ..." +} +``` + +Curl example: +``` +curl -v -X POST "http://localhost:8080/pkwmtt/api/v1/moderator/refresh" \ + -H "Content-Type: application/json" \ + -d "{\"refreshToken\":\"eyJ...\"}" +``` + + +### 3) POST `/logout` +- Path: +``` +POST /logout +Host: localhost:8080 +``` +- Request body: `RefreshRequestDto` JSON { "refreshToken": "..." } +- Success: 204 No Content + +Example request (HTTP-style): +``` +POST http://localhost:8080/pkwmtt/api/v1/moderator/logout +Content-Type: application/json +``` + +Curl example: +``` +curl -v -X POST "http://localhost:8080/pkwmtt/api/v1/moderator/logout" \ + -H "Content-Type: application/json" \ + -d "{\"refreshToken\":\"eyJ...\"}" +``` + + +### 4) POST `/users` (send student codes) +- Path: +``` +POST /users +Host: localhost:8080 +Content-Type: application/json +``` +- Request body: JSON array of `StudentCodeRequest` objects. Example element: +```json +{ + "email": "student@example.com", + "superiorGroupName": "12K1" +} +``` +- Behavior: + - If the array is null/empty → 400 Bad Request. + - On full success → 204 No Content. + - On partial failures (some emails failed) → 207 Multi-Status with a response body describing failures. +- Produces: application/json for the 207 response, empty body for 204. + +Example request (HTTP-style): +``` +POST http://localhost:8080/pkwmtt/api/v1/moderator/users +Content-Type: application/json +Accept: application/json +``` + +Request body (JSON): +```json +[ + { "email": "rep1@example.com", "superiorGroupName": "12K1" }, + { "email": "rep2@example.com", "superiorGroupName": "12K1" } +] +``` + +Curl example: +``` +curl -v -X POST "http://localhost:8080/pkwmtt/api/v1/moderator/users" \ + -H "Content-Type: application/json" \ + -d "[ { \"email\": \"rep1@example.com\", \"superiorGroupName\": \"12K1\" } ]" +``` + +Notes: +- The controller delegates sending codes to `StudentCodeService#sendStudentCode` and returns any failures as the 207 response body. + + +### 5) GET `/users` (list representatives) +- Path & example: +``` +GET http://localhost:8080/pkwmtt/api/v1/moderator/users +Accept: application/json +``` +- Returns: 200 OK with `List` JSON. + +Representative JSON fields (entity `org.pkwmtt.calendar.entity.Representative`): +```json +{ + "representativeId": "uuid", + "superiorGroup": { /* superior group object (id/name) depending on serialization) */ }, + "email": "rep@example.com", + "isActive": true +} +``` + + +Payload shapes + +AuthDto (from `org.pkwmtt.moderator.dto.AuthDto`): +```json +{ + "username": "moderator", + "password": "secret-password" +} +``` + +JwtAuthenticationDto (from `org.pkwmtt.security.authentication.dto.JwtAuthenticationDto`): +```json +{ + "accessToken": "eyJ...", + "refreshToken": "eyJ..." +} +``` + +RefreshRequestDto (from `org.pkwmtt.security.authentication.dto.RefreshRequestDto`): +```json +{ + "refreshToken": "eyJ..." +} +``` + +StudentCodeRequest (from `org.pkwmtt.studentCodes.dto.StudentCodeRequest`): +```json +{ + "email": "student@example.com", + "superiorGroupName": "12K1" +} +``` + +Representative (from `org.pkwmtt.calendar.entity.Representative`): +```json +{ + "representativeId": "uuid", + "superiorGroup": { /* may be nested object */ }, + "email": "rep@example.com", + "isActive": true +} +``` + + +Error handling and Controller Advice +- `ModeratorControllerAdvice` maps `SpecifiedGeneralGroupDoesntExistsException` to 404 Not Found and returns an `ErrorResponseDTO`. +- `ErrorResponseDTO` contains: +``` +message: string +timestamp: string +``` + +Example 404 response: +```json +{ + "message": "Specified general group doesn't exist: 12K1", + "timestamp": "2025-10-23T12:34:56.789" +} +``` + +Where to look in the codebase for details: +- Controller: `src/main/java/org/pkwmtt/moderator/controller/ModeratorController.java` +- Controller advice: `src/main/java/org/pkwmtt/moderator/controller/ModeratorControllerAdvice.java` +- DTOs and related classes referenced above are under `org.pkwmtt.*` packages (see their source files for exact fields and serialization behavior). + +Troubleshooting +- If you receive 400 for `POST /users`, ensure the request body is a non-empty JSON array of `StudentCodeRequest` objects. +- If tokens fail validation after `authenticate`/`refresh`, verify how the frontend stores and sends the `accessToken` as `Authorization: Bearer ` and refreshes when needed. + +Change log +- 2025-11-03 — initial documentation added for `ModeratorController` including examples and payload shapes. diff --git a/src/main/java/org/pkwmtt/moderator/controller/ModeratorController.java b/src/main/java/org/pkwmtt/moderator/controller/ModeratorController.java new file mode 100644 index 0000000..8afd7a0 --- /dev/null +++ b/src/main/java/org/pkwmtt/moderator/controller/ModeratorController.java @@ -0,0 +1,79 @@ +package org.pkwmtt.moderator.controller; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.calendar.events.dto.EventDTO; +import org.pkwmtt.calendar.events.services.EventsService; +import org.pkwmtt.calendar.exams.entity.Representative; +import org.pkwmtt.moderator.service.ModeratorService; +import org.pkwmtt.moderator.dto.AuthDto; +import org.pkwmtt.security.authentication.dto.JwtAuthenticationDto; +import org.pkwmtt.security.authentication.dto.RefreshRequestDto; +import org.pkwmtt.studentCodes.StudentCodeService; +import org.pkwmtt.studentCodes.dto.StudentCodeRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +/** + * Controller for moderator operations + */ +@RestController +@RequestMapping("/moderator") +@RequiredArgsConstructor +public class ModeratorController { + + private final ModeratorService moderatorService; + private final StudentCodeService studentCodeService; + private final EventsService eventsService; + + @PostMapping("/authenticate") + public ResponseEntity authenticate (@RequestBody AuthDto auth) { + return ResponseEntity.ok(moderatorService.generateTokenForModerator(auth.getPassword())); + } + + @PostMapping("/refresh") + public ResponseEntity refresh (@RequestBody RefreshRequestDto requestDto) { + return ResponseEntity.ok(moderatorService.refresh(requestDto)); + } + + @PostMapping("/logout") + public ResponseEntity logout (@RequestBody RefreshRequestDto requestDto) { + moderatorService.logout(requestDto); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/users") + public ResponseEntity addUsers (@RequestBody List studentCodeRequests) { + if (studentCodeRequests == null || studentCodeRequests.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + + var failures = studentCodeService.sendStudentCode(studentCodeRequests); + return (failures == null || failures.isEmpty()) + ? ResponseEntity.noContent().build() + : ResponseEntity.status(207).body(failures); + } + + @GetMapping("/users") + public ResponseEntity> getAllUsers () { + return ResponseEntity.ok(moderatorService.getUsers()); + } + + @PostMapping("/events") + public ResponseEntity addEvent (@RequestBody EventDTO event) { + return ResponseEntity.ok(eventsService.addEvent(event)); + } + + @GetMapping("/events") + public ResponseEntity> getAllEvents () { + return ResponseEntity.ok(eventsService.getAllEvents()); + } + + @PutMapping("/events") + public ResponseEntity updateEvent (@RequestBody EventDTO event) { + eventsService.updateEvent(event); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/org/pkwmtt/security/moderator/controller/ModeratorControllerAdvice.java b/src/main/java/org/pkwmtt/moderator/controller/ModeratorControllerAdvice.java similarity index 94% rename from src/main/java/org/pkwmtt/security/moderator/controller/ModeratorControllerAdvice.java rename to src/main/java/org/pkwmtt/moderator/controller/ModeratorControllerAdvice.java index 0263a15..79905bd 100644 --- a/src/main/java/org/pkwmtt/security/moderator/controller/ModeratorControllerAdvice.java +++ b/src/main/java/org/pkwmtt/moderator/controller/ModeratorControllerAdvice.java @@ -1,4 +1,4 @@ -package org.pkwmtt.security.moderator.controller; +package org.pkwmtt.moderator.controller; import org.pkwmtt.exceptions.SpecifiedGeneralGroupDoesntExistsException; import org.pkwmtt.exceptions.dto.ErrorResponseDTO; diff --git a/src/main/java/org/pkwmtt/security/moderator/dto/AuthDto.java b/src/main/java/org/pkwmtt/moderator/dto/AuthDto.java similarity index 64% rename from src/main/java/org/pkwmtt/security/moderator/dto/AuthDto.java rename to src/main/java/org/pkwmtt/moderator/dto/AuthDto.java index 4cb24ab..82875f7 100644 --- a/src/main/java/org/pkwmtt/security/moderator/dto/AuthDto.java +++ b/src/main/java/org/pkwmtt/moderator/dto/AuthDto.java @@ -1,4 +1,4 @@ -package org.pkwmtt.security.moderator.dto; +package org.pkwmtt.moderator.dto; import lombok.Getter; import lombok.Setter; @@ -6,5 +6,6 @@ @Getter @Setter public class AuthDto { + private String username; private String password; } diff --git a/src/main/java/org/pkwmtt/moderator/entities/Moderator.java b/src/main/java/org/pkwmtt/moderator/entities/Moderator.java new file mode 100644 index 0000000..3c8fdb3 --- /dev/null +++ b/src/main/java/org/pkwmtt/moderator/entities/Moderator.java @@ -0,0 +1,35 @@ +package org.pkwmtt.moderator.entities; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.UUID; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "moderators") +public class Moderator { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "moderator_id") + @JdbcTypeCode(SqlTypes.VARCHAR) + private UUID moderatorId; + + @Column(nullable = false) + private String password; + + @Column(length = 50) + private String role; + + public Moderator(String password) { + this.password = password; + this.role = "MODERATOR"; + } +} + diff --git a/src/main/java/org/pkwmtt/security/moderator/ModeratorRepository.java b/src/main/java/org/pkwmtt/moderator/repositories/ModeratorRepository.java similarity index 64% rename from src/main/java/org/pkwmtt/security/moderator/ModeratorRepository.java rename to src/main/java/org/pkwmtt/moderator/repositories/ModeratorRepository.java index 9a30010..406fc33 100644 --- a/src/main/java/org/pkwmtt/security/moderator/ModeratorRepository.java +++ b/src/main/java/org/pkwmtt/moderator/repositories/ModeratorRepository.java @@ -1,8 +1,10 @@ -package org.pkwmtt.security.moderator; +package org.pkwmtt.moderator.repositories; +import org.pkwmtt.moderator.entities.Moderator; import org.springframework.data.jpa.repository.JpaRepository; import java.util.UUID; public interface ModeratorRepository extends JpaRepository { } + diff --git a/src/main/java/org/pkwmtt/moderator/service/ModeratorService.java b/src/main/java/org/pkwmtt/moderator/service/ModeratorService.java new file mode 100644 index 0000000..c19f5aa --- /dev/null +++ b/src/main/java/org/pkwmtt/moderator/service/ModeratorService.java @@ -0,0 +1,94 @@ +package org.pkwmtt.moderator.service; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.calendar.exams.entity.Representative; +import org.pkwmtt.calendar.exams.repository.RepresentativeRepository; +import org.pkwmtt.exceptions.InvalidRefreshTokenException; +import org.pkwmtt.moderator.entities.Moderator; +import org.pkwmtt.moderator.repositories.ModeratorRepository; +import org.pkwmtt.security.authentication.dto.JwtAuthenticationDto; +import org.pkwmtt.security.authentication.dto.RefreshRequestDto; +import org.pkwmtt.security.jwt.JwtService; +import org.pkwmtt.security.jwt.refreshToken.entity.ModeratorRefreshToken; +import org.pkwmtt.security.jwt.refreshToken.entity.RefreshToken; +import org.pkwmtt.security.jwt.refreshToken.repository.ModeratorRefreshTokenRepository; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.UUID; + +@Service +@Transactional +@RequiredArgsConstructor +public class ModeratorService { + + private final ModeratorRepository moderatorRepository; + private final ModeratorRefreshTokenRepository moderatorRefreshTokenRepository; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + + private final RepresentativeRepository representativeRepository; + + public JwtAuthenticationDto generateTokenForModerator(String password) { + Moderator moderator = findModeratorByPassword(password); + return JwtAuthenticationDto.builder() + .accessToken(jwtService.generateModeratorAccessToken(moderator.getModeratorId())) + .refreshToken(getNewModeratorRefreshToken(moderator)) + .build(); + } + + public List getUsers() { + return representativeRepository.findAll(); + } + + + public JwtAuthenticationDto refresh(RefreshRequestDto requestDto) { + + ModeratorRefreshToken moderatorRefreshToken = findRefreshToken(requestDto.getRefreshToken()); + JwtService.validateRefreshToken(moderatorRefreshToken); + + String tokenHash = JwtService.generateRefreshToken(); + + moderatorRefreshToken.updateToken(passwordEncoder.encode(tokenHash)); + moderatorRefreshTokenRepository.save(moderatorRefreshToken); + + UUID id = moderatorRefreshToken.getModerator().getModeratorId(); + + return JwtAuthenticationDto.builder() + .refreshToken(tokenHash) + .accessToken(jwtService.generateModeratorAccessToken(id)) + .build(); + } + + public void logout(RefreshRequestDto requestDto) { + RefreshToken refreshToken = findRefreshToken(requestDto.getRefreshToken()); + if(!moderatorRefreshTokenRepository.deleteTokenAsBoolean(refreshToken.getToken())) + throw new InvalidRefreshTokenException(); + } + + private Moderator findModeratorByPassword(String password) throws ResponseStatusException { + return moderatorRepository.findAll() + .stream() + .filter(m -> passwordEncoder.matches(password, m.getPassword())) + .findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized")); + } + + private String getNewModeratorRefreshToken(Moderator moderator) { + String token = JwtService.generateRefreshToken(); + moderatorRefreshTokenRepository.save(new ModeratorRefreshToken(passwordEncoder.encode(token), moderator)); + return token; + } + + private ModeratorRefreshToken findRefreshToken(String token) + throws InvalidRefreshTokenException { + List refreshTokens = moderatorRefreshTokenRepository.findAll(); + return refreshTokens.stream() + .filter(rt -> passwordEncoder.matches(token, rt.getToken())) + .findFirst().orElseThrow(InvalidRefreshTokenException::new); + } +} diff --git a/src/main/java/org/pkwmtt/otp/OTPController.java b/src/main/java/org/pkwmtt/otp/OTPController.java deleted file mode 100644 index 22ddc1b..0000000 --- a/src/main/java/org/pkwmtt/otp/OTPController.java +++ /dev/null @@ -1,32 +0,0 @@ -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, IllegalArgumentException { - service.sendOTPCodesForManyGroups(request); - return ResponseEntity.ok().build(); - } - -} diff --git a/src/main/java/org/pkwmtt/otp/OTPService.java b/src/main/java/org/pkwmtt/otp/OTPService.java deleted file mode 100644 index 0d9a6b9..0000000 --- a/src/main/java/org/pkwmtt/otp/OTPService.java +++ /dev/null @@ -1,190 +0,0 @@ -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, IllegalArgumentException { - requests.forEach(this::sendOtpCode); - } - - public void sendOtpCode (OTPRequest request) - throws MailCouldNotBeSendException, WrongArgumentException, SpecifiedSubGroupDoesntExistsException, IllegalArgumentException { - 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))) { //Check general group name - throw new WrongArgumentException( - "Wrong general group provided. Make sure you are not providing subgroup. (f.e 12K1 -> wrong, 12K -> good)"); - } - - if (!generalGroupExists(groupName)) { // Check if general group with provided name exists - throw new SpecifiedGeneralGroupDoesntExistsException(); - } - - var generalGroup = generalGroupRepository.findByName(groupName); - - if (generalGroup.isPresent()) { //Check if general group is already saved in database - if (otpRepository.existsOTPCodeByGeneralGroup( - generalGroup.get())) { //Check if provided general group has assigned code - otpRepository.deleteByGeneralGroup(generalGroup.get()); // Delete existing code - } - } else { - //Save general group to database - generalGroup = Optional.of(generalGroupRepository.save(new GeneralGroup(null, groupName))); - } - - var userByEmail = userRepository.findByEmail(request.getEmail()); - - //Check if user isn't already assigned to different general group - if (userByEmail.isPresent()) { - if (!userByEmail.get() - .getGeneralGroup() - .equals(generalGroup.get())) { - throw new UserAlreadyAssignedException( - "User with this email is already assigned to different group."); - } - } - - try { - emailService.send(mail); //Send email - } 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 - .findByGeneralGroup(generalGroup.get()) - .ifPresent(value -> userRepository.deleteUserByEmail(value.getEmail())); - - 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 deleted file mode 100644 index af310f5..0000000 --- a/src/main/java/org/pkwmtt/otp/dto/OTPRequest.java +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 5f74dd7..0000000 --- a/src/main/java/org/pkwmtt/otp/repository/OTPCodeRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -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); - - @Transactional - void deleteByGeneralGroup (GeneralGroup generalGroup); -} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/reports/BugReportsController.java b/src/main/java/org/pkwmtt/reports/BugReportsController.java new file mode 100644 index 0000000..2eb051e --- /dev/null +++ b/src/main/java/org/pkwmtt/reports/BugReportsController.java @@ -0,0 +1,30 @@ +package org.pkwmtt.reports; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.reports.dto.BugReportDTO; +import org.pkwmtt.reports.dto.NewBugReportDTO; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequestMapping("${apiPrefix}/bug-reports") +@RequiredArgsConstructor +@RestController +public class BugReportsController { + private final BugReportsService service; + + @PostMapping("/report") + public ResponseEntity reportBug (@RequestBody NewBugReportDTO bugReportDTO) { + + service.addBugReport(new BugReportDTO( + 0, + bugReportDTO.getUserGroups(), + bugReportDTO.getDescription(), + bugReportDTO.getIssuedAt() + )); + + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/org/pkwmtt/reports/BugReportsService.java b/src/main/java/org/pkwmtt/reports/BugReportsService.java new file mode 100644 index 0000000..b7af424 --- /dev/null +++ b/src/main/java/org/pkwmtt/reports/BugReportsService.java @@ -0,0 +1,35 @@ +package org.pkwmtt.reports; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.pkwmtt.reports.dto.BugReportDTO; +import org.pkwmtt.reports.mapper.BugReportsMapper; +import org.pkwmtt.reports.repositories.BugReportRepository; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BugReportsService { + + private final BugReportRepository bugReportRepository; + + public List getAllBugReports () { + return bugReportRepository + .findAll() + .stream() + .map(BugReportsMapper::toDto) + .toList(); + } + + public void addBugReport (BugReportDTO bugReportDTO) { + var bugReport = BugReportsMapper.toEntity(bugReportDTO); + bugReportRepository.save(bugReport); + } + + @Transactional + public void removeBugReport (int id) { + bugReportRepository.deleteById(id); + } +} diff --git a/src/main/java/org/pkwmtt/reports/dto/BugReportDTO.java b/src/main/java/org/pkwmtt/reports/dto/BugReportDTO.java new file mode 100644 index 0000000..698a529 --- /dev/null +++ b/src/main/java/org/pkwmtt/reports/dto/BugReportDTO.java @@ -0,0 +1,17 @@ +package org.pkwmtt.reports.dto; + + +import lombok.Getter; + +import java.util.Date; + +@Getter +public class BugReportDTO extends NewBugReportDTO { + + int reportId; + + public BugReportDTO (int reportId, String userGroups, String description, Date issuedAt) { + super(userGroups, description, issuedAt); + this.reportId = reportId; + } +} diff --git a/src/main/java/org/pkwmtt/reports/dto/NewBugReportDTO.java b/src/main/java/org/pkwmtt/reports/dto/NewBugReportDTO.java new file mode 100644 index 0000000..5b93c74 --- /dev/null +++ b/src/main/java/org/pkwmtt/reports/dto/NewBugReportDTO.java @@ -0,0 +1,13 @@ +package org.pkwmtt.reports.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Date; +@AllArgsConstructor +@Getter +public class NewBugReportDTO { + String userGroups; + String description; + Date IssuedAt; +} diff --git a/src/main/java/org/pkwmtt/reports/entities/BugReport.java b/src/main/java/org/pkwmtt/reports/entities/BugReport.java new file mode 100644 index 0000000..f0a1f75 --- /dev/null +++ b/src/main/java/org/pkwmtt/reports/entities/BugReport.java @@ -0,0 +1,33 @@ +package org.pkwmtt.reports.entities; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@Entity +@Table(name = "bug_reports") +@Getter +@NoArgsConstructor +public class BugReport { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "report_id") + int reportId; + + @Column(name = "user_groups", columnDefinition = "VARCHAR(255)") + + String userGroups; + @Column(name = "description", columnDefinition = "VARCHAR(1000)") + String description; + + @Column(name = "issued_at", columnDefinition = "TIMESTAMP") + Date issuedAt; + + public BugReport (String userGroups, String description, Date issuedAt) { + this.userGroups = userGroups; + this.description = description; + this.issuedAt = issuedAt; + } +} diff --git a/src/main/java/org/pkwmtt/reports/mapper/BugReportsMapper.java b/src/main/java/org/pkwmtt/reports/mapper/BugReportsMapper.java new file mode 100644 index 0000000..065503f --- /dev/null +++ b/src/main/java/org/pkwmtt/reports/mapper/BugReportsMapper.java @@ -0,0 +1,36 @@ +package org.pkwmtt.reports.mapper; + +import org.pkwmtt.reports.dto.BugReportDTO; +import org.pkwmtt.reports.dto.NewBugReportDTO; +import org.pkwmtt.reports.entities.BugReport; + +public class BugReportsMapper { + private BugReportsMapper () { + } + + + public static BugReportDTO toDto (BugReport src) { + if (src == null) { + return null; + } + + return new BugReportDTO( + src.getReportId(), + src.getUserGroups(), + src.getDescription(), + src.getIssuedAt() + ); + } + + public static BugReport toEntity (NewBugReportDTO dto) { + if (dto == null) { + return null; + } + + return new BugReport( + dto.getUserGroups(), + dto.getDescription(), + dto.getIssuedAt() + ); + } +} diff --git a/src/main/java/org/pkwmtt/reports/repositories/BugReportRepository.java b/src/main/java/org/pkwmtt/reports/repositories/BugReportRepository.java new file mode 100644 index 0000000..e2137aa --- /dev/null +++ b/src/main/java/org/pkwmtt/reports/repositories/BugReportRepository.java @@ -0,0 +1,13 @@ +package org.pkwmtt.reports.repositories; + +import lombok.NonNull; +import org.springframework.data.jpa.repository.JpaRepository; +import org.pkwmtt.reports.entities.BugReport; + +import java.util.List; + +public interface BugReportRepository extends JpaRepository { + @Override + @NonNull + List findAll (); +} diff --git a/src/main/java/org/pkwmtt/security/apiKey/ApiKeyService.java b/src/main/java/org/pkwmtt/security/apiKey/ApiKeyService.java index 53b79da..cbd8b0f 100644 --- a/src/main/java/org/pkwmtt/security/apiKey/ApiKeyService.java +++ b/src/main/java/org/pkwmtt/security/apiKey/ApiKeyService.java @@ -1,48 +1,59 @@ package org.pkwmtt.security.apiKey; import lombok.RequiredArgsConstructor; -import org.pkwmtt.examCalendar.enums.Role; +import org.pkwmtt.calendar.exams.enums.Role; import org.pkwmtt.exceptions.IncorrectApiKeyValue; -import org.pkwmtt.security.admin.entity.AdminKey; -import org.pkwmtt.security.admin.repository.AdminKeyRepository; +import org.pkwmtt.admin.entity.AdminKey; +import org.pkwmtt.admin.repository.AdminKeyRepository; import org.pkwmtt.security.apiKey.entity.ApiKey; import org.pkwmtt.security.apiKey.repository.ApiKeyRepository; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; import java.util.UUID; +import static java.util.Objects.isNull; + @Service @RequiredArgsConstructor public class ApiKeyService { private final ApiKeyRepository apiKeyRepository; private final AdminKeyRepository adminKeyRepository; - + private final PasswordEncoder encoder; + 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)); - } + saveApiKey(value, description, role); return value; } + private void saveApiKey (String value, String description, Role role) { + String encodedValue = encoder.encode(value); + if (role == Role.ADMIN) { + adminKeyRepository.save(new AdminKey(encodedValue, description)); + } else { + apiKeyRepository.save(new ApiKey(encodedValue, description)); + } + } + public void validateApiKey (String value, Role role) throws IncorrectApiKeyValue { + if (isNull(value) || value.trim().isEmpty()) { + throw new 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 + if (role != Role.ADMIN && existsInPublicKeyBase(value)) { // Normal user access return; } @@ -50,11 +61,15 @@ public void validateApiKey (String value, Role role) throws IncorrectApiKeyValue } public boolean existsInPublicKeyBase (String value) { - return apiKeyRepository.existsApiKeyByValue(value); + return apiKeyRepository.findAll().stream() + .map(ApiKey::getValue) + .anyMatch(stored -> encoder.matches(value, stored)); } public boolean existsInAdminKeyBase (String value) { - return adminKeyRepository.existsApiKeyByValue(value); + return adminKeyRepository.findAll().stream() + .map(AdminKey::getValue) + .anyMatch(stored -> encoder.matches(value, stored)); } public Map getMapOfPublicApiKeys () { diff --git a/src/main/java/org/pkwmtt/security/authentication/JwtAuthenticationController.java b/src/main/java/org/pkwmtt/security/authentication/JwtAuthenticationController.java new file mode 100644 index 0000000..592d27c --- /dev/null +++ b/src/main/java/org/pkwmtt/security/authentication/JwtAuthenticationController.java @@ -0,0 +1,42 @@ +package org.pkwmtt.security.authentication; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.exceptions.MaxUsageForStudentCodeReachedException; +import org.pkwmtt.exceptions.StudentCodeNotFoundException; +import org.pkwmtt.exceptions.UserNotFoundException; +import org.pkwmtt.exceptions.WrongStudentCodeFormatException; +import org.pkwmtt.studentCodes.StudentCodeService; +import org.pkwmtt.studentCodes.dto.StudentCodeDTO; +import org.pkwmtt.security.authentication.dto.JwtAuthenticationDto; +import org.pkwmtt.security.authentication.dto.RefreshRequestDto; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +@Controller +@RequestMapping("${apiPrefix}/student") +@RequiredArgsConstructor +public class JwtAuthenticationController { + + private final JwtAuthenticationService jwtAuthenticationService; + private final StudentCodeService studentCodeService; + + @PostMapping("/authenticate") + public ResponseEntity authenticate (@RequestBody StudentCodeDTO code) + throws StudentCodeNotFoundException, WrongStudentCodeFormatException, UserNotFoundException, MaxUsageForStudentCodeReachedException { + return ResponseEntity.ok(studentCodeService.generateTokenForUser(code.getCode())); + } + + @PostMapping("/refresh") + public ResponseEntity refresh (@RequestBody RefreshRequestDto requestDto) { + return ResponseEntity.ok(jwtAuthenticationService.refresh(requestDto)); + } + + @PostMapping("/logout") + public ResponseEntity logout (@RequestBody RefreshRequestDto requestDto) { + jwtAuthenticationService.logout(requestDto); + return ResponseEntity.noContent().build(); + } + + +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/security/authentication/JwtAuthenticationService.java b/src/main/java/org/pkwmtt/security/authentication/JwtAuthenticationService.java new file mode 100644 index 0000000..4f7df34 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/authentication/JwtAuthenticationService.java @@ -0,0 +1,66 @@ +package org.pkwmtt.security.authentication; + +import io.jsonwebtoken.JwtException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.calendar.exams.entity.Representative; +import org.pkwmtt.exceptions.InvalidRefreshTokenException; +import org.pkwmtt.security.authentication.dto.JwtAuthenticationDto; +import org.pkwmtt.security.authentication.dto.RefreshRequestDto; +import org.pkwmtt.security.jwt.JwtService; +import org.pkwmtt.security.jwt.refreshToken.entity.RefreshToken; +import org.pkwmtt.security.jwt.refreshToken.entity.UserRefreshToken; +import org.pkwmtt.security.jwt.refreshToken.repository.UserRefreshTokenRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class JwtAuthenticationService { + private final JwtService jwtService; + private final UserRefreshTokenRepository userRefreshTokenRepository; + private final PasswordEncoder passwordEncoder; + + + public JwtAuthenticationDto refresh(RefreshRequestDto requestDto) throws JwtException { + + UserRefreshToken userRefreshToken = findRefreshToken(requestDto.getRefreshToken()); + JwtService.validateRefreshToken(userRefreshToken); + + String tokenHash = JwtService.generateRefreshToken(); + + userRefreshToken.updateToken(passwordEncoder.encode(tokenHash)); + userRefreshTokenRepository.save(userRefreshToken); + + Representative representative = userRefreshToken.getRepresentative(); + + return JwtAuthenticationDto.builder() + .refreshToken(tokenHash) + .accessToken(jwtService.generateAccessToken(representative)) + .build(); + } + + public void logout(RefreshRequestDto requestDto) { + RefreshToken refreshToken = findRefreshToken(requestDto.getRefreshToken()); + if(!userRefreshTokenRepository.deleteTokenAsBoolean(refreshToken.getToken())) + throw new InvalidRefreshTokenException(); + } + + public String getNewUserRefreshToken(Representative representative) { + String token = JwtService.generateRefreshToken(); + userRefreshTokenRepository.save(new UserRefreshToken(passwordEncoder.encode(token), representative)); + return token; + } + + private UserRefreshToken findRefreshToken(String token) + throws InvalidRefreshTokenException { + List refreshTokens = userRefreshTokenRepository.findAll(); + return refreshTokens.stream() + .filter(rt -> passwordEncoder.matches(token, rt.getToken())) + .findFirst().orElseThrow(InvalidRefreshTokenException::new); + } + +} diff --git a/src/main/java/org/pkwmtt/security/authentication/authenticationProvider/ModeratorAuthenticationProvider.java b/src/main/java/org/pkwmtt/security/authentication/authenticationProvider/ModeratorAuthenticationProvider.java new file mode 100644 index 0000000..0c3334e --- /dev/null +++ b/src/main/java/org/pkwmtt/security/authentication/authenticationProvider/ModeratorAuthenticationProvider.java @@ -0,0 +1,63 @@ +package org.pkwmtt.security.authentication.authenticationProvider; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.security.SignatureException; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.security.authentication.authenticationToken.JwtAuthenticationToken; +import org.pkwmtt.security.jwt.JwtService; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.Objects; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class ModeratorAuthenticationProvider implements AuthenticationProvider { + + private final JwtService jwtService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + +// get data + JwtAuthenticationToken auth = (JwtAuthenticationToken) authentication; + String token = auth.getCredentials(); + +// verify token and data + try { + if (!Objects.equals( + jwtService.extractClaim(token, claims -> claims.get("role", String.class)), + "ROLE_MODERATOR") + ) + return null; + } catch (ExpiredJwtException e) { + throw new CredentialsExpiredException("Token has expired"); + } catch (SignatureException e) { + throw new BadCredentialsException("Invalid JWT token"); + } catch (JwtException e) { + throw new AuthenticationServiceException("Authentication failed"); + } + +// verify user + UUID subject = UUID.fromString(jwtService.getSubject(token)); + +// authentication successful + GrantedAuthority role = new SimpleGrantedAuthority("ROLE_MODERATOR"); + return new JwtAuthenticationToken(subject, Collections.singletonList(role)); + } + + @Override + public boolean supports(Class authentication) { + return JwtAuthenticationToken.class.isAssignableFrom(authentication); + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/security/authentication/authenticationProvider/StudentAuthenticationProvider.java b/src/main/java/org/pkwmtt/security/authentication/authenticationProvider/StudentAuthenticationProvider.java new file mode 100644 index 0000000..26023b0 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/authentication/authenticationProvider/StudentAuthenticationProvider.java @@ -0,0 +1,64 @@ +package org.pkwmtt.security.authentication.authenticationProvider; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.security.SignatureException; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.security.authentication.authenticationToken.JwtAuthenticationToken; +import org.pkwmtt.security.jwt.JwtService; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.CredentialsExpiredException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.Objects; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class StudentAuthenticationProvider implements AuthenticationProvider { + + private final JwtService jwtService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + + // get data + JwtAuthenticationToken auth = (JwtAuthenticationToken) authentication; + String token = auth.getCredentials(); + +// verify token and data + try { + if (!Objects.equals( + jwtService.extractClaim(token, claims -> claims.get("role", String.class)), + "ROLE_STUDENT") + ) + return null; + } catch (ExpiredJwtException e) { + throw new CredentialsExpiredException("Token has expired"); + } catch (SignatureException e) { + throw new BadCredentialsException("Invalid JWT token"); + } catch (JwtException e) { + throw new AuthenticationServiceException("Authentication failed"); + } + +// verify user + UUID subject = UUID.fromString(jwtService.getSubject(token)); + String superiorGroup = jwtService.extractClaim(token, claims -> claims.get("group", String.class)); + +// authentication successful + GrantedAuthority role = new SimpleGrantedAuthority("ROLE_STUDENT"); + return new JwtAuthenticationToken(subject, Collections.singletonList(role), superiorGroup); + } + + @Override + public boolean supports(Class authentication) { + return JwtAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/org/pkwmtt/security/authentication/authenticationToken/JwtAuthenticationToken.java b/src/main/java/org/pkwmtt/security/authentication/authenticationToken/JwtAuthenticationToken.java new file mode 100644 index 0000000..836f956 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/authentication/authenticationToken/JwtAuthenticationToken.java @@ -0,0 +1,78 @@ +package org.pkwmtt.security.authentication.authenticationToken; + +import lombok.Getter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; +import java.util.UUID; + +public class JwtAuthenticationToken extends AbstractAuthenticationToken { + + private UUID principal; + private String jwtToken; + @Getter + private String superiorGroup; + + + /** + * This constructor can be safely used by any code that wishes to create a JwtAuthenticationToken, + * as the isAuthenticated() will return false + * + * @param jwtToken + */ + public JwtAuthenticationToken(String jwtToken) { + super(null); + this.jwtToken = jwtToken; + setAuthenticated(false); + } + + /** + * This constructor should only be used by AuthenticationManager or AuthenticationProvider + * implementations that are satisfied with producing a trusted (i.e. isAuthenticated() = true) + * authentication token. It refers to users with authorities for specific group only + * + * @param principal + * @param authorities + * @param superiorGroup + */ + public JwtAuthenticationToken(UUID principal, Collection authorities, String superiorGroup) { + super(authorities); + this.principal = principal; + this.jwtToken = null; + this.superiorGroup = superiorGroup; + super.setAuthenticated(true); + } + + /** + * This constructor should only be used by AuthenticationManager or AuthenticationProvider + * implementations that are satisfied with producing a trusted (i.e. isAuthenticated() = true) + * authentication token. It refers to users without authorities for specific groups + * + * @param principal + * @param authorities + */ + public JwtAuthenticationToken(UUID principal, Collection authorities) { + super(authorities); + this.principal = principal; + this.jwtToken = null; + this.superiorGroup = null; + super.setAuthenticated(true); + } + + @Override + public String getCredentials() { + return this.jwtToken; + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + @Override + public void eraseCredentials() { + super.eraseCredentials(); + this.jwtToken = null; + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/security/authentication/config/AuthenticationConfig.java b/src/main/java/org/pkwmtt/security/authentication/config/AuthenticationConfig.java new file mode 100644 index 0000000..145542a --- /dev/null +++ b/src/main/java/org/pkwmtt/security/authentication/config/AuthenticationConfig.java @@ -0,0 +1,17 @@ +package org.pkwmtt.security.authentication.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ProviderManager; + +import java.util.List; + +@Configuration +public class AuthenticationConfig { + @Bean + public AuthenticationManager jwtAuthenticationManager (List authenticationProviders) { + return new ProviderManager(authenticationProviders); + } +} diff --git a/src/main/java/org/pkwmtt/security/authentication/dto/JwtAuthenticationDto.java b/src/main/java/org/pkwmtt/security/authentication/dto/JwtAuthenticationDto.java new file mode 100644 index 0000000..7d4c19f --- /dev/null +++ b/src/main/java/org/pkwmtt/security/authentication/dto/JwtAuthenticationDto.java @@ -0,0 +1,11 @@ +package org.pkwmtt.security.authentication.dto; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class JwtAuthenticationDto { + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/org/pkwmtt/security/authentication/dto/RefreshRequestDto.java b/src/main/java/org/pkwmtt/security/authentication/dto/RefreshRequestDto.java new file mode 100644 index 0000000..53c8524 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/authentication/dto/RefreshRequestDto.java @@ -0,0 +1,8 @@ +package org.pkwmtt.security.authentication.dto; + +import lombok.Getter; + +@Getter +public class RefreshRequestDto { + private String refreshToken; +} diff --git a/src/main/java/org/pkwmtt/security/authorization/PreAuthorizationService.java b/src/main/java/org/pkwmtt/security/authorization/PreAuthorizationService.java new file mode 100644 index 0000000..4495817 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/authorization/PreAuthorizationService.java @@ -0,0 +1,79 @@ +package org.pkwmtt.security.authorization; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.calendar.exams.repository.ExamRepository; +import org.pkwmtt.exceptions.NoSuchElementWithProvidedIdException; +import org.pkwmtt.security.authentication.authenticationToken.JwtAuthenticationToken; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.pkwmtt.calendar.exams.mapper.GroupMapper.extractSuperiorGroup; + +//TODO: handle AccessDeniedException + +@Service +@RequiredArgsConstructor +public class PreAuthorizationService { + + private final ExamRepository examRepository; + + /** + * verifies if user has authorities to add new resource + * + * @param newGroups set of provided groups + */ + public boolean verifyGroupPermissionsForNewResource(Set newGroups) { + String userGroup = getUserGroup(); + return extractSuperiorGroup(newGroups).equals(userGroup); + } + + /** + * verifies if user has authorities to modify existing resource + * also check if resource exists + * + * @param examId id of existing resource + * @throws NoSuchElementWithProvidedIdException when resource don't exist + */ + public boolean verifyGroupPermissionsForExistingResource(Integer examId) throws NoSuchElementWithProvidedIdException { + String userGroup = getUserGroup(); + Set generalGroupsOfExam = examRepository.findGroupsByExamId(examId) + .stream() + .filter(group -> group.matches("^\\d.*")) + .collect(Collectors.toSet()); + return extractSuperiorGroup(generalGroupsOfExam).equals(userGroup); + } + + + /** + * verifies if user had authorities to replace existing resource with new one + * also check if modified exam exists + * + * @param newGroups set of groups of new resource + * @param examId id of existing resource + * @throws NoSuchElementWithProvidedIdException when resource don't exist + */ + public boolean verifyGroupPermissionsForModifiedResource(Set newGroups, Integer examId) throws NoSuchElementWithProvidedIdException { + examRepository.findById(examId).orElseThrow(() -> new NoSuchElementWithProvidedIdException(examId)); + return verifyGroupPermissionsForNewResource(newGroups) && verifyGroupPermissionsForExistingResource(examId); + } + + /** + * @return superior group identifier (e.g. 12K) of currently authenticated user + * @throws AccessDeniedException when user doesn't have assigned group + */ + private String getUserGroup() throws AccessDeniedException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication instanceof JwtAuthenticationToken jwtAuthentication) { + String group = jwtAuthentication.getSuperiorGroup(); + if (group != null && !group.isBlank()) + return group; + } + throw new AccessDeniedException("You don't have permission to access this group"); + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/security/config/SpringSecurity.java b/src/main/java/org/pkwmtt/security/config/SpringSecurity.java index dd19628..aedaf1b 100644 --- a/src/main/java/org/pkwmtt/security/config/SpringSecurity.java +++ b/src/main/java/org/pkwmtt/security/config/SpringSecurity.java @@ -2,10 +2,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.pkwmtt.security.token.filter.JwtFilter; +import org.pkwmtt.calendar.exams.enums.Role; +import org.pkwmtt.security.filter.JwtFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -16,6 +18,7 @@ import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; @EnableWebSecurity +@EnableMethodSecurity @Slf4j @Configuration @RequiredArgsConstructor @@ -30,11 +33,12 @@ public SecurityFilterChain filterChain (HttpSecurity http) throws Exception { .cors(withDefaults()) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.POST , "/pkwmtt/api/v1/exams").authenticated() - .requestMatchers(HttpMethod.PUT , "/pkwmtt/api/v1/exams").authenticated() - .requestMatchers(HttpMethod.DELETE , "/pkwmtt/api/v1/exams").authenticated() + .requestMatchers(HttpMethod.POST , "/pkwmtt/api/v1/exams").hasRole("STUDENT") + .requestMatchers(HttpMethod.PUT , "/pkwmtt/api/v1/exams").hasRole("STUDENT") + .requestMatchers(HttpMethod.DELETE , "/pkwmtt/api/v1/exams").hasRole("STUDENT") .requestMatchers("/moderator/authenticate").permitAll() - .requestMatchers("/moderator/**").hasAuthority("ROLE_MODERATOR") + .requestMatchers("/moderator/refresh").permitAll() + .requestMatchers("/moderator/**").hasRole(Role.MODERATOR.toString()) .requestMatchers("/**").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/org/pkwmtt/security/filter/JwtFilter.java b/src/main/java/org/pkwmtt/security/filter/JwtFilter.java new file mode 100644 index 0000000..0dbe85c --- /dev/null +++ b/src/main/java/org/pkwmtt/security/filter/JwtFilter.java @@ -0,0 +1,54 @@ +package org.pkwmtt.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.security.authentication.authenticationToken.JwtAuthenticationToken; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final AuthenticationManager jwtAuthenticationManager; + /** + * Filters incoming HTTP requests to validate JWT tokens. + * + *

This filter: + * - Extracts the JWT token from the Authorization header. + * - Delegates token validation to jwtAuthenticationManager + * - 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, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.startsWith("Bearer ") && SecurityContextHolder.getContext().getAuthentication() == null) { + + String token = authHeader.substring(7); + Authentication authToken = jwtAuthenticationManager.authenticate(new JwtAuthenticationToken(token)); + + SecurityContextHolder.getContext().setAuthentication(authToken); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/security/token/JwtServiceImpl.java b/src/main/java/org/pkwmtt/security/jwt/JwtService.java similarity index 56% rename from src/main/java/org/pkwmtt/security/token/JwtServiceImpl.java rename to src/main/java/org/pkwmtt/security/jwt/JwtService.java index 3bf5e2a..84ffab6 100644 --- a/src/main/java/org/pkwmtt/security/token/JwtServiceImpl.java +++ b/src/main/java/org/pkwmtt/security/jwt/JwtService.java @@ -1,16 +1,19 @@ -package org.pkwmtt.security.token; +package org.pkwmtt.security.jwt; 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.pkwmtt.calendar.exams.entity.Representative; +import org.pkwmtt.exceptions.InvalidRefreshTokenException; +import org.pkwmtt.security.jwt.refreshToken.entity.RefreshToken; +import org.pkwmtt.security.jwt.utils.JwtUtils; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; +import java.security.SecureRandom; +import java.time.LocalDateTime; import java.util.Base64; import java.util.Date; import java.util.UUID; @@ -18,7 +21,8 @@ @Service @RequiredArgsConstructor -public class JwtServiceImpl implements JwtService { +public class JwtService { + private final JwtUtils jwtUtils; /** @@ -26,32 +30,44 @@ public class JwtServiceImpl implements JwtService { * 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 + * @param representative - required user data to include in token claims * @return signed JWT token as a String */ - @Override - public String generateToken(UserDTO user) { + public String generateAccessToken(Representative representative) { return Jwts.builder() - .subject(user.getEmail()) - .claim("group", user.getGroup()) - .claim("role", user.getRole()) + .subject(representative.getRepresentativeId().toString()) + .claim("group", representative.getSuperiorGroup().getName()) + .claim("role", "ROLE_STUDENT") //TODO: enum .issuedAt(new Date()) .expiration((new Date(System.currentTimeMillis() + jwtUtils.getExpirationMs()))) .signWith(decodeSecretKey()) .compact(); } - @Override - public String generateToken(UUID uuid) { + public String generateModeratorAccessToken(UUID uuid) { return Jwts.builder() .subject(uuid.toString()) - .claim("role", "MODERATOR") + .claim("role", "ROLE_MODERATOR") //TODO: enum .issuedAt(new Date()) - .expiration((new Date(System.currentTimeMillis() + jwtUtils.getModeratorExpirationMs()))) + .expiration((new Date(System.currentTimeMillis() + jwtUtils.getExpirationMs()))) .signWith(decodeSecretKey()) .compact(); } + public static String generateRefreshToken() { + SecureRandom random = new SecureRandom(); + byte[] randomBytes = new byte[32]; + random.nextBytes(randomBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + } + + + public static void validateRefreshToken(RefreshToken rt) throws InvalidRefreshTokenException { + if (rt.getExpires().isBefore(LocalDateTime.now())) + throw new InvalidRefreshTokenException(); + } + + /** * Decode a secret key for signing JWT. * The key is decoded from Base64 stored in JwtUtils configuration. @@ -63,36 +79,6 @@ SecretKey decodeSecretKey(){ 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 = getSubject(token); - return userEmail != null - && userEmail.equals(user.getEmail()) - && !isTokenExpired(token); - } catch (JwtException | IllegalArgumentException e) { - return false; - } - } - - @Override - public Boolean validateToken(String token, String uuid) { - try { - final String userid = getSubject(token); - return userid != null - && userid.equals(uuid) - && !isTokenExpired(token); - } catch (JwtException | IllegalArgumentException e) { - return false; - } - } /** * Extracts the user identifier (email) from a JWT token. @@ -100,30 +86,10 @@ public Boolean validateToken(String token, String uuid) { * @param token JWT token to extract user from * @return user email from token */ - @Override public String getSubject(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. diff --git a/src/main/java/org/pkwmtt/security/jwt/refreshToken/entity/ModeratorRefreshToken.java b/src/main/java/org/pkwmtt/security/jwt/refreshToken/entity/ModeratorRefreshToken.java new file mode 100644 index 0000000..37d97c9 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/jwt/refreshToken/entity/ModeratorRefreshToken.java @@ -0,0 +1,41 @@ +package org.pkwmtt.security.jwt.refreshToken.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.pkwmtt.moderator.entities.Moderator; + +import java.time.LocalDateTime; + +@Entity +@NoArgsConstructor +@Getter +@Table(name = "moderator_refresh_tokens") +public class ModeratorRefreshToken implements RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer token_id; + + private String token; + + @ManyToOne + @JoinColumn(name = "moderator_id") + private Moderator moderator; + + private LocalDateTime created; + + private LocalDateTime expires; + + public ModeratorRefreshToken(String token, Moderator moderator) { + this.token = token; + this.moderator = moderator; + this.created = LocalDateTime.now(); + this.expires = LocalDateTime.now().plusMonths(6); + } + + public void updateToken(String token) { + this.token = token; + this.created = LocalDateTime.now(); + } + + +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/security/jwt/refreshToken/entity/RefreshToken.java b/src/main/java/org/pkwmtt/security/jwt/refreshToken/entity/RefreshToken.java new file mode 100644 index 0000000..310729a --- /dev/null +++ b/src/main/java/org/pkwmtt/security/jwt/refreshToken/entity/RefreshToken.java @@ -0,0 +1,8 @@ +package org.pkwmtt.security.jwt.refreshToken.entity; + +import java.time.LocalDateTime; + +public interface RefreshToken { + String getToken(); + LocalDateTime getExpires(); +} diff --git a/src/main/java/org/pkwmtt/security/jwt/refreshToken/entity/UserRefreshToken.java b/src/main/java/org/pkwmtt/security/jwt/refreshToken/entity/UserRefreshToken.java new file mode 100644 index 0000000..cd4b923 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/jwt/refreshToken/entity/UserRefreshToken.java @@ -0,0 +1,41 @@ +package org.pkwmtt.security.jwt.refreshToken.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.pkwmtt.calendar.exams.entity.Representative; + +import java.time.LocalDateTime; + +@Entity +@NoArgsConstructor +@Getter +@Table(name = "user_refresh_tokens") +public class UserRefreshToken implements RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer token_id; + + private String token; + + @ManyToOne + @JoinColumn(name = "representative_id") + private Representative representative; + + @Column(name = "created_at") + private LocalDateTime created; + + @Column(name = "expires_at") + private LocalDateTime expires; + + public UserRefreshToken (String token, Representative representative) { + this.token = token; + this.representative = representative; + this.created = LocalDateTime.now(); + this.expires = LocalDateTime.now().plusMonths(6); + } + + public void updateToken (String token) { + this.token = token; + this.created = LocalDateTime.now(); + } +} diff --git a/src/main/java/org/pkwmtt/security/jwt/refreshToken/repository/ModeratorRefreshTokenRepository.java b/src/main/java/org/pkwmtt/security/jwt/refreshToken/repository/ModeratorRefreshTokenRepository.java new file mode 100644 index 0000000..202d6bc --- /dev/null +++ b/src/main/java/org/pkwmtt/security/jwt/refreshToken/repository/ModeratorRefreshTokenRepository.java @@ -0,0 +1,10 @@ +package org.pkwmtt.security.jwt.refreshToken.repository; + +import org.pkwmtt.security.jwt.refreshToken.entity.ModeratorRefreshToken; +import org.springframework.stereotype.Repository; + +@Repository +public interface ModeratorRefreshTokenRepository extends RefreshTokenRepository { + + +} diff --git a/src/main/java/org/pkwmtt/security/jwt/refreshToken/repository/RefreshTokenRepository.java b/src/main/java/org/pkwmtt/security/jwt/refreshToken/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..3ab5fec --- /dev/null +++ b/src/main/java/org/pkwmtt/security/jwt/refreshToken/repository/RefreshTokenRepository.java @@ -0,0 +1,14 @@ +package org.pkwmtt.security.jwt.refreshToken.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; + +@NoRepositoryBean +public interface RefreshTokenRepository extends JpaRepository { + + long deleteByToken(String token); + + default Boolean deleteTokenAsBoolean(String token) { + return deleteByToken(token) > 0; + } +} diff --git a/src/main/java/org/pkwmtt/security/jwt/refreshToken/repository/UserRefreshTokenRepository.java b/src/main/java/org/pkwmtt/security/jwt/refreshToken/repository/UserRefreshTokenRepository.java new file mode 100644 index 0000000..cdf3d40 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/jwt/refreshToken/repository/UserRefreshTokenRepository.java @@ -0,0 +1,10 @@ +package org.pkwmtt.security.jwt.refreshToken.repository; + +import org.pkwmtt.security.jwt.refreshToken.entity.UserRefreshToken; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRefreshTokenRepository extends RefreshTokenRepository{ + + +} diff --git a/src/main/java/org/pkwmtt/security/token/utils/JwtUtils.java b/src/main/java/org/pkwmtt/security/jwt/utils/JwtUtils.java similarity index 78% rename from src/main/java/org/pkwmtt/security/token/utils/JwtUtils.java rename to src/main/java/org/pkwmtt/security/jwt/utils/JwtUtils.java index 827e982..04f28bc 100644 --- a/src/main/java/org/pkwmtt/security/token/utils/JwtUtils.java +++ b/src/main/java/org/pkwmtt/security/jwt/utils/JwtUtils.java @@ -1,9 +1,11 @@ -package org.pkwmtt.security.token.utils; +package org.pkwmtt.security.jwt.utils; import lombok.Getter; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + @Getter @Component public class JwtUtils { @@ -11,8 +13,7 @@ public class JwtUtils { // 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; - private final long moderatorExpirationMs = 1000L * 60 * 60 * 2; + private final long expirationMs = TimeUnit.MINUTES.toMillis(5); public JwtUtils(Environment environment) { diff --git a/src/main/java/org/pkwmtt/security/moderator/Moderator.java b/src/main/java/org/pkwmtt/security/moderator/Moderator.java deleted file mode 100644 index 4570395..0000000 --- a/src/main/java/org/pkwmtt/security/moderator/Moderator.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.pkwmtt.security.moderator; - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.UUID; - -@Entity -@Table(name = "moderators") -@Getter -@NoArgsConstructor -public class Moderator { - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID moderatorId; - private String password; - private String role; - - public Moderator(String encryptedPassword) { - password = encryptedPassword; - role = "MODERATOR"; - } -} diff --git a/src/main/java/org/pkwmtt/security/moderator/ModeratorService.java b/src/main/java/org/pkwmtt/security/moderator/ModeratorService.java deleted file mode 100644 index f1602ff..0000000 --- a/src/main/java/org/pkwmtt/security/moderator/ModeratorService.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.pkwmtt.security.moderator; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.pkwmtt.examCalendar.entity.User; -import org.pkwmtt.examCalendar.repository.UserRepository; -import org.pkwmtt.security.token.JwtService; -import org.springframework.http.HttpStatus; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.web.server.ResponseStatusException; - -import java.util.List; - -@Service -@Transactional -@RequiredArgsConstructor -public class ModeratorService { - - private final ModeratorRepository moderatorRepository; - private final JwtService jwtService; - private final PasswordEncoder passwordEncoder; - - private final UserRepository userRepository; - - public String generateTokenForModerator(String password) { - return moderatorRepository.findAll() - .stream() - .filter(m -> passwordEncoder.matches(password, m.getPassword())) - .findFirst() - .map(m -> jwtService.generateToken(m.getModeratorId())) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized")); - } - - public List getUsers() { - return userRepository.findAll(); - } -} diff --git a/src/main/java/org/pkwmtt/security/moderator/controller/ModeratorController.java b/src/main/java/org/pkwmtt/security/moderator/controller/ModeratorController.java deleted file mode 100644 index f108234..0000000 --- a/src/main/java/org/pkwmtt/security/moderator/controller/ModeratorController.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.pkwmtt.security.moderator.controller; - -import lombok.RequiredArgsConstructor; -import org.pkwmtt.examCalendar.entity.User; -import org.pkwmtt.otp.OTPService; -import org.pkwmtt.otp.dto.OTPRequest; -import org.pkwmtt.security.moderator.ModeratorService; -import org.pkwmtt.security.moderator.dto.AuthDto; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -@RestController -@RequestMapping("/moderator") -@RequiredArgsConstructor -public class ModeratorController { - - private final ModeratorService moderatorService; - private final OTPService otpService; - - @PostMapping("/authenticate") - public ResponseEntity authenticate (@RequestBody AuthDto auth) { - return ResponseEntity.ok(moderatorService.generateTokenForModerator(auth.getPassword())); - } - - @PostMapping("/users") - public ResponseEntity addUser (@RequestBody OTPRequest otpRequest) { - otpService.sendOtpCode(otpRequest); - return ResponseEntity.noContent().build(); - } - - @PostMapping("/multiple-users") - public ResponseEntity addMultipleUser (@RequestBody List otpRequests) { - otpService.sendOTPCodesForManyGroups(otpRequests); - return ResponseEntity.noContent().build(); - } - - @GetMapping("/users") - public ResponseEntity> getAllUsers() { - return ResponseEntity.ok(moderatorService.getUsers()); - } -} diff --git a/src/main/java/org/pkwmtt/security/token/JwtAuthenticationToken.java b/src/main/java/org/pkwmtt/security/token/JwtAuthenticationToken.java deleted file mode 100644 index 1bdb61f..0000000 --- a/src/main/java/org/pkwmtt/security/token/JwtAuthenticationToken.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.pkwmtt.security.token; - -import lombok.Getter; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; - -import java.util.Collection; - -public class JwtAuthenticationToken extends UsernamePasswordAuthenticationToken { - - @Getter - private String examGroup; - - - public JwtAuthenticationToken(Object principal, Collection authorities) { - super(principal, null, authorities); - } - - public JwtAuthenticationToken(Object principal, Collection authorities, String group) { - super(principal, null, authorities); - this.examGroup = group; - } - -} diff --git a/src/main/java/org/pkwmtt/security/token/JwtService.java b/src/main/java/org/pkwmtt/security/token/JwtService.java deleted file mode 100644 index 8aa697f..0000000 --- a/src/main/java/org/pkwmtt/security/token/JwtService.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.pkwmtt.security.token; - -import io.jsonwebtoken.Claims; -import org.pkwmtt.examCalendar.entity.User; -import org.pkwmtt.security.token.dto.UserDTO; - -import java.util.UUID; -import java.util.function.Function; - -public interface JwtService { - String generateToken(UserDTO user); - String generateToken(UUID uuid); - Boolean validateToken(String token, User user); - Boolean validateToken(String token, String uuid); - String getSubject(String token); - T extractClaim(String token, Function claimResolver); -} diff --git a/src/main/java/org/pkwmtt/security/token/dto/UserDTO.java b/src/main/java/org/pkwmtt/security/token/dto/UserDTO.java deleted file mode 100644 index 2c69368..0000000 --- a/src/main/java/org/pkwmtt/security/token/dto/UserDTO.java +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 28e6394..0000000 --- a/src/main/java/org/pkwmtt/security/token/filter/JwtFilter.java +++ /dev/null @@ -1,119 +0,0 @@ -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.moderator.ModeratorRepository; -import org.pkwmtt.security.token.JwtAuthenticationToken; -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; -import java.util.UUID; - -@Component -public class JwtFilter extends OncePerRequestFilter { - - @Autowired - JwtService jwtService; - - @Autowired - UserRepository userRepository; - - @Autowired - ModeratorRepository moderatorRepository; - - /** - * 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 subject = null; - - if (authHeader != null && authHeader.startsWith("Bearer ")) { - token = authHeader.substring(7); - subject = jwtService.getSubject(token); - } - - if (subject != null && SecurityContextHolder.getContext().getAuthentication() == null) { - String role = jwtService.extractClaim(token, claims -> claims.get("role", String.class)); - - - if (role.equals("MODERATOR")) - filterModerator(request, token, subject); - else - filterUser(request, token, subject); - } - - filterChain.doFilter(request, response); - } - - private void filterModerator(HttpServletRequest request, String token, String subject) { - UUID uuid = UUID.fromString(subject); - moderatorRepository.findById(uuid).orElseThrow(); //TODO: add exception type - - if (jwtService.validateToken(token, subject)) { - List authorities = List.of( - new SimpleGrantedAuthority("ROLE_" + "MODERATOR") - ); - - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - subject, - null, - authorities - ); - - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authToken); - - } - } - - private void filterUser(HttpServletRequest request, String token, String subject) { - User user = userRepository.findByEmail(subject).orElseThrow(); - - if (jwtService.validateToken(token, user)) { - List authorities = List.of( - new SimpleGrantedAuthority("ROLE_" + user.getRole()) - ); - - UsernamePasswordAuthenticationToken authToken = - new JwtAuthenticationToken( - user.getEmail(), - authorities, - jwtService.extractClaim(token, claims -> claims.get("group", String.class)) - ); - - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authToken); - } - } -} diff --git a/src/main/java/org/pkwmtt/studentCodes/SendStudentCodeFailure.java b/src/main/java/org/pkwmtt/studentCodes/SendStudentCodeFailure.java new file mode 100644 index 0000000..1b2e875 --- /dev/null +++ b/src/main/java/org/pkwmtt/studentCodes/SendStudentCodeFailure.java @@ -0,0 +1,14 @@ +package org.pkwmtt.studentCodes; + +/** + * Immutable DTO that represents a failure occurred while sending an OTP code to a group. + * Contains the group identifier, a human-readable message and the exception class name + * (useful for diagnostics without serializing full exception stack traces). + * + * @param superiorGroupName The name of the superior group for which sending OTP failed. + * @param reason Short, single-line reason for the failure (safe for display). + * @param exceptionClass Simple name of the exception class that was thrown (e.g. MailCouldNotBeSendException). + */ +public record SendStudentCodeFailure(String superiorGroupName, String reason, String exceptionClass) { +} + diff --git a/src/main/java/org/pkwmtt/otp/OTPExceptionHandler.java b/src/main/java/org/pkwmtt/studentCodes/StudentCodeExceptionHandler.java similarity index 71% rename from src/main/java/org/pkwmtt/otp/OTPExceptionHandler.java rename to src/main/java/org/pkwmtt/studentCodes/StudentCodeExceptionHandler.java index 6494829..0fe12d8 100644 --- a/src/main/java/org/pkwmtt/otp/OTPExceptionHandler.java +++ b/src/main/java/org/pkwmtt/studentCodes/StudentCodeExceptionHandler.java @@ -1,10 +1,10 @@ -package org.pkwmtt.otp; +package org.pkwmtt.studentCodes; import com.mysql.cj.exceptions.WrongArgumentException; import org.pkwmtt.exceptions.*; import org.pkwmtt.exceptions.dto.ErrorResponseDTO; -import org.pkwmtt.security.moderator.controller.ModeratorController; +import org.pkwmtt.moderator.controller.ModeratorController; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -12,9 +12,9 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @Order(2) -@RestControllerAdvice(assignableTypes = {OTPController.class, ModeratorController.class}) -public class OTPExceptionHandler { - @ExceptionHandler({OTPCodeNotFoundException.class, WrongOTPFormatException.class, UserNotFoundException.class, WrongArgumentException.class, SpecifiedGeneralGroupDoesntExistsException.class, IllegalArgumentException.class}) +@RestControllerAdvice(assignableTypes = {ModeratorController.class}) +public class StudentCodeExceptionHandler { + @ExceptionHandler({StudentCodeNotFoundException.class, WrongStudentCodeFormatException.class, UserNotFoundException.class, WrongArgumentException.class, SpecifiedGeneralGroupDoesntExistsException.class, IllegalArgumentException.class}) public ResponseEntity handleBadRequests (Exception e) { return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.BAD_REQUEST); } diff --git a/src/main/java/org/pkwmtt/studentCodes/StudentCodeService.java b/src/main/java/org/pkwmtt/studentCodes/StudentCodeService.java new file mode 100644 index 0000000..9a51c32 --- /dev/null +++ b/src/main/java/org/pkwmtt/studentCodes/StudentCodeService.java @@ -0,0 +1,376 @@ +package org.pkwmtt.studentCodes; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.mysql.cj.exceptions.WrongArgumentException; +import jakarta.mail.MessagingException; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.calendar.enities.SuperiorGroup; +import org.pkwmtt.calendar.exams.entity.StudentCode; +import org.pkwmtt.calendar.exams.entity.Representative; +import org.pkwmtt.calendar.exams.repository.SuperiorGroupRepository; +import org.pkwmtt.calendar.exams.repository.RepresentativeRepository; +import org.pkwmtt.exceptions.*; +import org.pkwmtt.mail.EmailService; +import org.pkwmtt.mail.dto.MailDTO; +import org.pkwmtt.security.jwt.JwtService; +import org.pkwmtt.studentCodes.dto.StudentCodeRequest; +import org.pkwmtt.studentCodes.repository.StudentCodeRepository; +import org.pkwmtt.security.authentication.JwtAuthenticationService; +import org.pkwmtt.security.authentication.dto.JwtAuthenticationDto; +import org.pkwmtt.timetable.TimetableService; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class StudentCodeService { + private final StudentCodeRepository studentCodeRepository; + private final RepresentativeRepository representativeRepository; + private final SuperiorGroupRepository superiorGroupRepository; + private final EmailService emailService; + private final JwtService jwtService; + private final JwtAuthenticationService jwtAuthenticationService; + private final TimetableService timetableService; + + /** + * Generate authentication tokens for a user that provides a valid student code. + * The method checks the code existence and format, verifies usage limits, + * maps the code to a representative and returns JWT tokens for that representative. + * + * @param code student code string (expected format validated by {@link #validateCode(String)}) + * @return {@link JwtAuthenticationDto} containing access and refresh tokens + * @throws StudentCodeNotFoundException if the code does not exist in the repository + * @throws WrongStudentCodeFormatException if the code does not match expected format + * @throws UserNotFoundException if no representative is associated with the code's group + * @throws MaxUsageForStudentCodeReachedException if the code's usage reached its usage limit + */ + public JwtAuthenticationDto generateTokenForUser (String code) + throws StudentCodeNotFoundException, WrongStudentCodeFormatException, UserNotFoundException, MaxUsageForStudentCodeReachedException { + var codeEntity = this.getEntityByCode(code); + + checkUsageLimit(codeEntity); + + var representative = findRepresentativeForCode(codeEntity); + + var jwtDto = createTokensForRepresentative(representative); + + increaseUsage(code); + + return jwtDto; + } + + /** + * Validate that the provided code entity has not exceeded its usage limit. + * + * @param codeEntity {@link StudentCode} entity to check + * @throws MaxUsageForStudentCodeReachedException when usage is >= usageLimit + */ + private void checkUsageLimit (StudentCode codeEntity) throws MaxUsageForStudentCodeReachedException { + if (codeEntity.getUsage() >= codeEntity.getUsageLimit()) { + throw new MaxUsageForStudentCodeReachedException("This code has reached its maximum usage limit."); + } + } + + /** + * Find the representative assigned to the superior group referenced by the student code. + * + * @param codeEntity {@link StudentCode} that contains a reference to {@link SuperiorGroup} + * @return {@link Representative} associated with the group + * @throws UserNotFoundException if no representative is assigned to the group + */ + private Representative findRepresentativeForCode (StudentCode codeEntity) throws UserNotFoundException { + return representativeRepository + .findBySuperiorGroup(codeEntity.getSuperiorGroup()) + .orElseThrow(() -> new UserNotFoundException("No representative is assigned to this code.")); + } + + /** + * Create access and refresh tokens for the provided representative. + * + * @param representative the representative for whom tokens will be issued + * @return {@link JwtAuthenticationDto} containing access and refresh tokens + */ + private JwtAuthenticationDto createTokensForRepresentative (Representative representative) { + var accessToken = jwtService.generateAccessToken(representative); + var refreshToken = jwtAuthenticationService.getNewUserRefreshToken(representative); + + return JwtAuthenticationDto + .builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + /** + * Increment usage counter for the student code identified by the code string. + * + * @param code code to increment usage for + */ + private void increaseUsage (String code) { + studentCodeRepository.increaseUsageByCode(code); + } + + /** + * Send student codes for multiple requests. This method processes each {@link StudentCodeRequest} + * independently and collects failures (per-request) into a list of {@link SendStudentCodeFailure}. + * + * @param requests list of requests to process + * @return list of failures encountered while processing requests; empty list indicates all succeeded + */ + public List sendStudentCode (List requests) { + // Collect per-group failures and return them to the caller so they can decide what to do. + var failures = new java.util.ArrayList(); + for (StudentCodeRequest request : requests) { + try { + sendStudentCode(request); + } catch (Exception e) { + String group = request.getSuperiorGroupName(); + String reason = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + reason = reason.replaceAll("\\r?\\n", " "); + failures.add(new SendStudentCodeFailure(group, reason, e.getClass().getSimpleName())); + } + } + + return failures; + } + + /** + * Send a student code to a single {@link StudentCodeRequest}. This method: + * - generates a new unique code, + * - validates the provided general group name, + * - ensures a superior group exists (creates if missing), + * - ensures the email is not already assigned to another representative, + * - assigns a representative to the group, saves the code and representative, + * - sends out an email with the code. + * + * @param request request containing recipient email, group name and mail template + * @throws MailCouldNotBeSendException when sending the email fails + * @throws WrongArgumentException when provided group name format is invalid + * @throws SpecifiedSubGroupDoesntExistsException when subgroup is specified instead of general group + * @throws IllegalArgumentException for other invalid arguments + * @throws JsonProcessingException when timetable service fails to provide the group list + */ + public void sendStudentCode (StudentCodeRequest request) + throws MailCouldNotBeSendException, WrongArgumentException, SpecifiedSubGroupDoesntExistsException, IllegalArgumentException, JsonProcessingException { + var code = generateNewCode(); + var mail = createMail(request, code); + var groupName = request.getSuperiorGroupName(); + + validateGroupNameFormat(groupName); + + if (!generalGroupExists(groupName)) { // Check if general group with provided name exists + throw new SpecifiedGeneralGroupDoesntExistsException(); + } + + var superiorGroup = findOrCreateSuperiorGroup(groupName); + ensureNoExistingRepresentativeByEmail(request.getEmail()); + + var representative = buildRepresentative(request.getEmail(), superiorGroup.get()); + replaceExistingRepresentativeForGroup(superiorGroup.get()); + representativeRepository.save(representative); + studentCodeRepository.save(new StudentCode(code, superiorGroup.get())); + + sendEmailOrThrow(mail, groupName); + } + + /** + * Validate that the provided group name is formatted as a general group (not subgroup). + * + * @param groupName name to validate + * @throws WrongArgumentException when the name appears to include subgroup suffix (ends with digit) + */ + private void validateGroupNameFormat (String groupName) throws WrongArgumentException { + var groupNameLength = groupName.length(); + if (groupNameLength > 3 && Character.isDigit( + groupName.charAt(groupNameLength - 1))) { //Check general group name + throw new WrongArgumentException( + "Wrong general group provided. Make sure you are not providing subgroup. (f.e 12K1 -> wrong, 12K -> good)"); + } + } + + /** + * Find an existing {@link SuperiorGroup} by name or create and persist a new one. + * If a student code already exists for the found group it will be removed to avoid duplicates. + * + * @param groupName name of the superior group + * @return {@link Optional} containing the found or newly created {@link SuperiorGroup} + */ + private Optional findOrCreateSuperiorGroup (String groupName) { + var superiorGroup = superiorGroupRepository.findByName(groupName); + if (superiorGroup.isPresent()) { + if (studentCodeRepository.existsBySuperiorGroup(superiorGroup.get())) { + studentCodeRepository.deleteBySuperiorGroup(superiorGroup.get()); + } + return superiorGroup; + } else { + return Optional.of(superiorGroupRepository.save(new SuperiorGroup(null, groupName))); + } + } + + /** + * Ensure that no other representative is already registered with the provided email. + * + * @param email email address to check + * @throws UserAlreadyAssignedException when another representative exists for the email + */ + private void ensureNoExistingRepresentativeByEmail (String email) { + var representativeByEmail = representativeRepository.findByEmail(email); + if (representativeByEmail.isPresent()) { + throw new UserAlreadyAssignedException( + "Representative with email: " + email + " already has assigned different group."); + } + } + + /** + * Send the email using {@link EmailService}. Wrapes low-level {@link MessagingException} + * into a domain-specific {@link MailCouldNotBeSendException}. + * + * @param mail mail DTO to be sent + * @param groupName group name used to provide contextual error message + * @throws MailCouldNotBeSendException when underlying mail sending fails + */ + private void sendEmailOrThrow (MailDTO mail, String groupName) throws MailCouldNotBeSendException { + try { + emailService.send(mail); + } catch (MessagingException e) { + throw new MailCouldNotBeSendException("Couldn't send mail for group: " + groupName); + } + } + + /** + * Helper to build a {@link Representative} entity from provided email and group. + * + * @param email representative email + * @param superiorGroup group to assign to representative + * @return constructed {@link Representative} + */ + private Representative buildRepresentative (String email, SuperiorGroup superiorGroup) { + return Representative + .builder() + .email(email) + .superiorGroup(superiorGroup) + .isActive(true) + .build(); + } + + /** + * Replace (delete) an existing representative assigned to the specified superior group. + * + * @param superiorGroup target group for which existing representative should be removed + */ + private void replaceExistingRepresentativeForGroup (SuperiorGroup superiorGroup) { + representativeRepository + .findBySuperiorGroup(superiorGroup) + .ifPresent(value -> representativeRepository.deleteRepresentativeByEmail(value.getEmail())); + } + + + /** + * Retrieve {@link StudentCode} entity by its code string after validating its format. + * + * @param code code to lookup + * @return {@link StudentCode} entity associated with code + * @throws StudentCodeNotFoundException when no entity is found + * @throws WrongStudentCodeFormatException when provided code format is invalid + */ + private StudentCode getEntityByCode (String code) + throws StudentCodeNotFoundException, WrongStudentCodeFormatException { + this.validateCode(code); + + Optional result = studentCodeRepository.findByCode(code); + + if (result.isEmpty()) { + throw new StudentCodeNotFoundException(); + } + + return result.get(); + } + + /** + * Validate code length and allowed characters. + * + * @param code code string to validate + * @throws WrongStudentCodeFormatException when code is not exactly 6 characters or contains invalid chars + */ + private void validateCode (String code) throws WrongStudentCodeFormatException { + if (code.length() != 6) { + throw new WrongStudentCodeFormatException("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 WrongStudentCodeFormatException("Wrong format of provided code."); + } + } + + + /** + * Create a {@link MailDTO} to be sent for a generated student code. + * + * @param request original request containing recipient and mail message template + * @param code generated student code to be included in the mail + * @return configured {@link MailDTO} + */ + private MailDTO createMail (StudentCodeRequest request, String code) { + return new MailDTO() + .setTitle("Kod Starosty " + request.getSuperiorGroupName()) + .setRecipient(request.getEmail()) + .setDescription(request.getMailMessage(code)); + } + + /** + * Generate a new unique 6-character alphanumeric code. The method loops until + * a code not present in the repository is produced. + * + * @return newly generated unique code + */ + private String generateNewCode () { + String AVAILABLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + StringBuilder code = new StringBuilder(); + SecureRandom random = new SecureRandom(); + + do { + code.setLength(0); + for (int i = 0; i < 6; i++) { + code.append(AVAILABLE_CHARS.charAt(random.nextInt(AVAILABLE_CHARS.length()))); + } + } while (studentCodeRepository.findByCode(code.toString()).isPresent()); + + return code.toString(); + } + + /** + * Check whether the provided general group name exists in the timetable service. + * The timetable returns group strings which may include subgroup suffixes; this + * method normalizes those to their general group names before checking. + * + * @param name general group name to verify + * @return true when the general group exists; false otherwise + * @throws JsonProcessingException when the timetable service cannot provide or parse group data + */ + private boolean generalGroupExists (String name) throws JsonProcessingException { + 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); + } + +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/studentCodes/dto/StudentCodeDTO.java b/src/main/java/org/pkwmtt/studentCodes/dto/StudentCodeDTO.java new file mode 100644 index 0000000..a05118e --- /dev/null +++ b/src/main/java/org/pkwmtt/studentCodes/dto/StudentCodeDTO.java @@ -0,0 +1,10 @@ +package org.pkwmtt.studentCodes.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class StudentCodeDTO { + private String code; +} diff --git a/src/main/java/org/pkwmtt/studentCodes/dto/StudentCodeRequest.java b/src/main/java/org/pkwmtt/studentCodes/dto/StudentCodeRequest.java new file mode 100644 index 0000000..4d136a6 --- /dev/null +++ b/src/main/java/org/pkwmtt/studentCodes/dto/StudentCodeRequest.java @@ -0,0 +1,23 @@ +package org.pkwmtt.studentCodes.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class StudentCodeRequest { + private String email; + private String superiorGroupName; + + public String getMailMessage (String code) { + return String.format( + """ + Kod grupy %s
+ Twój kod: %s

+ Poniżej znajduje się kod służący do odblokowania możliwości dodawania/edytowanie/usuwania wydarzeń w kalendarzu dla twojej grupy.
+ Wpisz kod w [Ustawienia > Wpisz kod] i przekaż go innym osobom.
+ Twórcy aplikacji nie ponoszą odpowiedzialności za niewłaściwe użycie kodu przez osoby trzecie.

+ """, superiorGroupName, code + ); + } +} diff --git a/src/main/java/org/pkwmtt/studentCodes/repository/StudentCodeRepository.java b/src/main/java/org/pkwmtt/studentCodes/repository/StudentCodeRepository.java new file mode 100644 index 0000000..e7a7987 --- /dev/null +++ b/src/main/java/org/pkwmtt/studentCodes/repository/StudentCodeRepository.java @@ -0,0 +1,27 @@ +package org.pkwmtt.studentCodes.repository; + +import jakarta.transaction.Transactional; +import org.pkwmtt.calendar.enities.SuperiorGroup; +import org.pkwmtt.calendar.exams.entity.StudentCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface StudentCodeRepository extends JpaRepository { + Optional findByCode(String code); + + boolean existsBySuperiorGroup(SuperiorGroup superiorGroup); + + boolean existsByCode(String code); + + @Query("UPDATE StudentCode sc SET sc.usage = sc.usage + 1 WHERE sc.code = ?1") + @Modifying + @Transactional + void increaseUsageByCode(String code); + + @Transactional + void deleteBySuperiorGroup(SuperiorGroup superiorGroup); + +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/timetable/TIMETABLE.MD b/src/main/java/org/pkwmtt/timetable/TIMETABLE.MD new file mode 100644 index 0000000..738c9e1 --- /dev/null +++ b/src/main/java/org/pkwmtt/timetable/TIMETABLE.MD @@ -0,0 +1,353 @@ +# Timetable Controller — API Reference + +This document explains the REST endpoints exposed by `TimetableController`. + +Base URL: + +http://localhost:8080/pkwmtt/api/v1/timetables + +Example general group used in this document: `12K1` with example subgroups `K01`, `L01`, `P01`. + +Summary / quick checklist +- Use `Accept: application/json` for all requests and `Content-Type: application/json` for requests with a body. +- When you want filtered results by subgroup(s), include `sub` query parameter(s). If `sub` is missing, the controller returns a cached timetable and ignores any request body. +- When applying custom subject filters, POST to `/{generalGroupName}` with `sub` parameters present. + +## Endpoints + +### 1) GET `/{generalGroupName}` +- Path: +```http +GET /{generalGroupName} +Host: localhost:8080 +``` +- Query params: +```text +sub (optional, repeatable) e.g. ?sub=K01&sub=L01&sub=P01 +``` +- Behavior: + - If no `sub` provided → returns cached timetable for the given general group. + - If `sub` provided → returns timetable filtered by the provided subgroup(s). +- Success: 200 OK with `TimetableDTO` JSON. +- Errors: 4xx/5xx depending on exception mapping. + +Example (cached timetable): +```http +GET http://localhost:8080/pkwmtt/api/v1/timetables/12K1 +Accept: application/json +``` + +Example (filtered by 3 subgroups): +```http +GET http://localhost:8080/pkwmtt/api/v1/timetables/12K1?sub=K01&sub=L01&sub=P01 +Accept: application/json +``` + + +### 2) POST `/{generalGroupName}` (apply custom subject filters) +- Path: +```http +POST /{generalGroupName} +Host: localhost:8080 +Content-Type: application/json +``` +- Query params: +```text +sub (optional, repeatable) — must be present to apply filters; otherwise body is ignored. +``` +- Request body: optional JSON array of `CustomSubjectFilterDTO` objects. +- Produces: application/json + +Example request (HTTP-style): +```http +POST http://localhost:8080/pkwmtt/api/v1/timetables/12K1?sub=K01 +Content-Type: application/json +Accept: application/json +``` + +Request body (JSON): +```json +[ + { + "name": "Mathematics", + "generalGroup": "12K1", + "subGroup": "K01" + } +] +``` + +Curl example (Windows / bash-compatible): +```bash +curl -v -X POST "http://localhost:8080/pkwmtt/api/v1/timetables/12K1?sub=K01" \ + -H "Content-Type: application/json" \ + -d '[{"name":"Mathematics","generalGroup":"12K1","subGroup":"K01"}]' +``` + +Notes: +- If `sub` is absent, this endpoint behaves like the GET cached endpoint and ignores the request body. + + +### 3) GET `/hours` +- Path: +```http +GET /hours +Host: localhost:8080 +``` +- Example: +```http +GET http://localhost:8080/pkwmtt/api/v1/timetables/hours +Accept: application/json +``` +- Returns: 200 OK with a JSON array of canonical hour ranges. + + +### 4) GET `/groups/general` +- Path & example: +```http +GET http://localhost:8080/pkwmtt/api/v1/timetables/groups/general +Accept: application/json +``` +- Returns: 200 OK with List of general group names. + + +### 5) GET `/groups/{generalGroupName}` +- Example: +```http +GET http://localhost:8080/pkwmtt/api/v1/timetables/groups/12K1 +Accept: application/json +``` +- Returns: 200 OK with List of subgroup names (e.g. K01, L01, P01). + + +### 6) GET `/groups/{generalGroupName}/{subjectName}` +- Example: +```http +GET http://localhost:8080/pkwmtt/api/v1/timetables/groups/12K1/Mathematics +Accept: application/json +``` +- Returns: 200 OK with List of subgroup names in which the subject appears. + + +### 7) GET `/{generalGroupName}/list` +- Example: +```http +GET http://localhost:8080/pkwmtt/api/v1/timetables/12K1/list +Accept: application/json +``` +- Returns: 200 OK with List of subject names for the group. + + +### 8) GET `/{generalGroupName}/list/custom` +- Example: +```http +GET http://localhost:8080/pkwmtt/api/v1/timetables/12K1/list/custom +Accept: application/json +``` +- Returns: 200 OK with List of custom subject names. + + +Payload shapes: + +CustomSubjectFilterDTO (actual fields from `org.pkwmtt.timetable.dto.CustomSubjectFilterDTO`) + +```json +{ + "name": "Mathematics", + "generalGroup": "12K1", + "subGroup": "K01" +} +``` + +TimetableDTO (actual fields from `org.pkwmtt.timetable.dto.TimetableDTO`) + +- Root object fields: + - `name` (String) — timetable name (general group name) + - `data` (Array of `DayOfWeekDTO`) — list of days with their subjects + +Example `TimetableDTO` JSON (matching the project's DTO classes): + +```json +{ + "name": "12K1", + "data": [ + { + "name": "MONDAY", + "odd": [ + { + "name": "Mathematics", + "classroom": "101", + "rowId": 1, + "type": "LECTURE", + "custom": false + } + ], + "even": [ + { + "name": "Physics", + "classroom": "202", + "rowId": 2, + "type": "LAB", + "custom": false + } + ] + } + ] +} +``` + +Field mapping summary (DTO -> JSON) +- `TimetableDTO` -> { name: String, data: DayOfWeekDTO[] } +- `DayOfWeekDTO` -> { name: String, odd: SubjectDTO[], even: SubjectDTO[] } +- `SubjectDTO` -> { name: String, classroom: String, rowId: int, type: String, custom: boolean } + +TypeScript interface suggestions (matching actual DTOs) + +```typescript +interface CustomSubjectFilterDTO { + name: string; + generalGroup?: string; + subGroup?: string; +} + +interface SubjectDTO { + name: string; + classroom?: string; + rowId?: number; + type?: string; // matches SubjectType enum on the backend + custom?: boolean; +} + +interface DayOfWeekDTO { + name: string; // e.g. "MONDAY" + odd: SubjectDTO[]; + even: SubjectDTO[]; +} + +interface TimetableDTO { + name: string; // e.g. "12K1" + data: DayOfWeekDTO[]; +} +``` + + +Frontend integration notes and gotchas +- Always include `sub` query parameter(s) when you expect subgroup-specific behavior. If you omit `sub`, the controller will return the cached timetable and ignore the body of POST requests. +- The controller uses two services: + - `TimetableService` — used for live parsing and filtering (used when `sub` is present or when the controller delegates parsing operations). + - `TimetableCacheService` — returns cached timetables and cheap metadata reads (used when `sub` is absent). +- Treat `CustomSubjectFilterDTO` arrays as optional in the POST body. If you pass no body but include `sub`, the controller will treat it as an empty list of custom filters. +- Handle server errors (4xx/5xx) with user-friendly messages and optionally a retry for 5xx or 503. +- Strings (dates and times) follow ISO-like formats in responses; however, confirm exact formats if you rely on strict parsing. + + +Example curl commands (localhost & example group `12K1`) + +1) Get cached timetable for 12K1 + +```bash +curl -v "http://localhost:8080/pkwmtt/api/v1/timetables/12K1" -H "Accept: application/json" +``` + +2) Get timetable filtered for subgroups K01, L01, P01 + +```bash +curl -v "http://localhost:8080/pkwmtt/api/v1/timetables/12K1?sub=K01&sub=L01&sub=P01" -H "Accept: application/json" +``` + +3) Post custom filters for subgroup K01 + +```bash +curl -v -X POST "http://localhost:8080/pkwmtt/api/v1/timetables/12K1?sub=K01" \ + -H "Content-Type: application/json" \ + -d '[{"name":"Mathematics","generalGroup":"12K1","subGroup":"K01"}]' +``` + +### Possible errors and ErrorMessageDTO (TimetableExceptionHandler) + +This section documents how exceptions thrown by `TimetableController` are mapped to HTTP responses by `TimetableExceptionHandler` and the JSON shape returned to callers. + +Handler source: `src/main/java/org/pkwmtt/timetable/TimetableExceptionHandler.java` + +Error response DTO: `src/main/java/org/pkwmtt/exceptions/dto/ErrorResponseDTO.java` + +ErrorResponseDTO fields: + +```text +message: string // human-readable error message +timestamp: string // server LocalDateTime when the error was created +``` + +Exception -> HTTP mapping (as implemented in the handler): + +- 503 Service Unavailable + - Exception: `WebPageContentNotAvailableException` + - Returned status: 503 + - Body: `ErrorResponseDTO` with the exception message + +Example response (503): +```json +{ + "message": "Source page content not available", + "timestamp": "2025-10-23T12:34:56.789" +} +``` + +- 500 Internal Server Error (JSON processing) + - Exception: `JsonProcessingException` (handler returns a generic message) + - Returned status: 500 + - Body: `ErrorResponseDTO` with message set to `"Json Processing Failed"` + +Example response (500 - JSON processing): +```json +{ + "message": "Json Processing Failed", + "timestamp": "2025-10-23T12:34:56.789" +} +``` + +- 400 Bad Request + - Exceptions handled: `SpecifiedGeneralGroupDoesntExistsException`, `SpecifiedSubGroupDoesntExistsException`, `IllegalArgumentException` + - Returned status: 400 + - Body: `ErrorResponseDTO` containing the exception message (explains what input was invalid or missing) + +Example response (400): +```json +{ + "message": "Specified general group doesn't exist: 12K1", + "timestamp": "2025-10-23T12:34:56.789" +} +``` + +- 500 Internal Server Error (access/other unexpected) + - Exceptions handled: `IllegalAccessException`, `org.apache.logging.log4j.util.InternalException` + - Returned status: 500 + - Body: `ErrorResponseDTO` containing the exception message + +Example response (500 - internal): +```json +{ + "message": "Unexpected internal error: ...", + "timestamp": "2025-10-23T12:34:56.789" +} +``` + +Notes for clients: + +- Always check the HTTP status code and parse the `ErrorResponseDTO` JSON for a user-visible message and server timestamp. +- For 503 (service unavailable) prefer a short backoff retry. For 4xx errors present the `message` to the user to correct the request. +- The exact exception messages come from server code; do not rely on their exact wording for program logic — use the HTTP status for decision-making. + +Where to look in the codebase for details: + +- Exception handler: `src/main/java/org/pkwmtt/timetable/TimetableExceptionHandler.java` +- Error DTO: `src/main/java/org/pkwmtt/exceptions/dto/ErrorResponseDTO.java` + + +Troubleshooting +- If you receive unexpected 404 for a valid group name, confirm the value is exactly as returned by `GET /groups/general` or `GET /groups/{generalGroupName}`. +- If filters seem ignored, verify `sub` parameters are present on the request URL (they are required for the controller to use the parsing service). +- For transient network or parsing problems the controller may throw a `WebPageContentNotAvailableException` (surface to the user as a 5xx/503 error). + + +Change log +- 2025-10-23 — initial documentation added for `TimetableController` including localhost examples and `12K1` group example. diff --git a/src/main/java/org/pkwmtt/timetable/TimetableCacheService.java b/src/main/java/org/pkwmtt/timetable/TimetableCacheService.java index 449e7ea..2f24cd2 100644 --- a/src/main/java/org/pkwmtt/timetable/TimetableCacheService.java +++ b/src/main/java/org/pkwmtt/timetable/TimetableCacheService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.jsoup.Jsoup; +import org.pkwmtt.exceptions.CacheContentNotAvailableException; import org.pkwmtt.exceptions.SpecifiedGeneralGroupDoesntExistsException; import org.pkwmtt.exceptions.WebPageContentNotAvailableException; import org.pkwmtt.timetable.dto.TimetableDTO; @@ -13,16 +14,32 @@ import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; import java.io.IOException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; import java.util.List; import java.util.Map; +/** + * Service for caching and retrieving timetable data. + * This service interacts with a remote timetable source, parses the data, + * and caches the results for efficient retrieval. + */ @Service public class TimetableCacheService { + /** + * Dependencies + */ private final TimetableParserService parser; private final ObjectMapper mapper; private final Cache cache; + /** + * Base URL for the timetable source + */ @Value("${main.url:https://podzial.mech.pk.edu.pl/stacjonarne/html/}") private String mainUrl; @@ -33,29 +50,27 @@ public TimetableCacheService (TimetableParserService parser, ObjectMapper mapper } /** - * Fetches and parses the full timetable for a general group. + * Retrieves the timetable for a specified general group. * - * @param generalGroupName group to fetch - * @return parsed timetable - * @throws WebPageContentNotAvailableException if remote content is unavailable + * @param generalGroupName the name of the general group + * @return TimetableDTO containing the timetable data + * @throws WebPageContentNotAvailableException if the timetable page can't be fetched + * @throws SpecifiedGeneralGroupDoesntExistsException if the specified general group doesn't exist */ public TimetableDTO getGeneralGroupSchedule (String generalGroupName) - throws WebPageContentNotAvailableException, SpecifiedGeneralGroupDoesntExistsException { - var generalGroupMap = getGeneralGroupsMap(); + throws WebPageContentNotAvailableException, SpecifiedGeneralGroupDoesntExistsException, JsonProcessingException { + Map generalGroupMap = getGeneralGroupsMap(); if (!generalGroupMap.containsKey(generalGroupName)) { throw new SpecifiedGeneralGroupDoesntExistsException(generalGroupName); } - String groupUrl = generalGroupMap.get(generalGroupName); - String url = mainUrl + groupUrl; + String url = mainUrl + generalGroupMap.get(generalGroupName); String cacheKey = "timetable_" + generalGroupName; - var html = fetchData(url); + String json = cache.get( - cacheKey, () -> { - var timetableDTO = new TimetableDTO(generalGroupName, parser.parse(html)); - return mapper.writeValueAsString(timetableDTO); - } + cacheKey, + () -> mapper.writeValueAsString(new TimetableDTO(generalGroupName, parser.parse(fetchData(url)))) ); return getMappedValue( @@ -65,17 +80,17 @@ public TimetableDTO getGeneralGroupSchedule (String generalGroupName) } /** - * Retrieves a mapping of general group names to their corresponding timetable URLs. + * Retrieves a map of general group names to their corresponding timetable URLs. * - * @return map of group names to URLs - * @throws WebPageContentNotAvailableException if the source page can't be fetched + * @return Map where keys are general group names and values are their timetable URLs + * @throws WebPageContentNotAvailableException if the general groups page can't be loaded */ - public Map getGeneralGroupsMap () throws WebPageContentNotAvailableException { - var url = mainUrl + "lista.html"; - var html = fetchData(url); + public Map getGeneralGroupsMap () + throws WebPageContentNotAvailableException, JsonProcessingException { + String url = mainUrl + "lista.html"; String json = cache.get( "generalGroupMap", - () -> mapper.writeValueAsString(parser.parseGeneralGroups(html)) + () -> mapper.writeValueAsString(parser.parseGeneralGroups(fetchData(url))) ); return getMappedValue( @@ -85,36 +100,29 @@ public Map getGeneralGroupsMap () throws WebPageContentNotAvaila } /** - * Retrieves the standard list of hour ranges used in the timetable. + * Retrieves a hard-coded list of timetable hours. * - * @return list of hour labels (e.g., 08:00–09:30) - * @throws WebPageContentNotAvailableException if hour definition page can't be loaded + * @return List of strings representing timetable hours + * @throws WebPageContentNotAvailableException if there were trouble with fetching data */ public List getListOfHours () throws WebPageContentNotAvailableException { //Hard coded values for hours, caused by inconsistent timetable hours range return List.of( - "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" + "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" ); } + /** + * Fetches and parses the list of timetable hours from the remote source. + * + * @return List of strings representing timetable hours + * @throws WebPageContentNotAvailableException if there were trouble with fetching data + */ @SuppressWarnings("unused") - private List fetchListOfHours () { + private List fetchListOfHours () throws JsonProcessingException { String url = mainUrl + "plany/o25.html"; String json = cache.get("hourList", () -> mapper.writeValueAsString(parser.parseHours(fetchData(url)))); @@ -129,33 +137,76 @@ private List fetchListOfHours () { } /** - * @param json - json representation of java object - * @param key - cache key - * @param cache - cache object - * @param targetClass - type to map value to - * @param type of object - * @return java object type - * @throws WebPageContentNotAvailableException if there were trouble with fetching data + * Maps a JSON string to a specified type, evicting the cache entry on failure. + * + * @param json the JSON string to be mapped + * @param key the cache key associated with the JSON string + * @param cache the cache instance + * @param typeRef the TypeReference indicating the target type for mapping + * @param the type of the mapped object + * @return the mapped object of type T + * @throws CacheContentNotAvailableException if mapping fails */ - private T getMappedValue (String json, String key, Cache cache, TypeReference targetClass) - throws WebPageContentNotAvailableException { + private T getMappedValue (String json, String key, Cache cache, TypeReference typeRef) + throws JsonProcessingException { try { - return mapper.readValue(json, targetClass); + return mapper.readValue(json, typeRef); } catch (JsonProcessingException e) { cache.evict(key); - throw new WebPageContentNotAvailableException(); + throw e; } } +// /** +// * Fetches the HTML content of the specified URL using Jsoup. +// * +// *

This method performs a blocking HTTP GET request and returns the raw +// * HTML content as a String. Any IO-related error encountered while +// * connecting to or reading from the remote resource is translated into a +// * {@link WebPageContentNotAvailableException} to decouple callers from +// * low-level IO exceptions.

+// * +// * @param url the target URL to fetch HTML from +// * @return the HTML content of the page as a String +// * @throws WebPageContentNotAvailableException when an I/O error occurs while fetching the page +// */ +// private static String fetchData (String url) throws WebPageContentNotAvailableException { +// try { +// return Jsoup.connect(url).get().html(); +// } catch (IOException ioe) { +// throw new WebPageContentNotAvailableException(); +// } +// } + /** - * @param url - url of webpage - * @return html code of selected webpage - * @throws WebPageContentNotAvailableException if there were trouble with fetching data + * Temporary solution for issues with university certificate. + * Resolve problem by disabling certification verification + * Should be replaced with better solution in the future + * @param url the target URL to fetch HTML from + * @return the HTML content of the page as a String + * @throws WebPageContentNotAvailableException when an I/O error occurs while fetching the page */ - private static String fetchData (String url) throws WebPageContentNotAvailableException { +// FIXME: Replace with better solution + private static String fetchData(String url) throws WebPageContentNotAvailableException { try { - return Jsoup.connect(url).get().html(); - } catch (IOException ioe) { + TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { return null; } + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + public void checkServerTrusted(X509Certificate[] certs, String authType) {} + }}; + + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(null, trustAllCerts, new SecureRandom()); + + return Jsoup.connect(url) + .sslSocketFactory(sc.getSocketFactory()) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36") + .timeout(10000) + .get() + .html(); + + } catch (Exception e) { + e.printStackTrace(); throw new WebPageContentNotAvailableException(); } } diff --git a/src/main/java/org/pkwmtt/timetable/TimetableController.java b/src/main/java/org/pkwmtt/timetable/TimetableController.java index d40a0f4..58fe442 100644 --- a/src/main/java/org/pkwmtt/timetable/TimetableController.java +++ b/src/main/java/org/pkwmtt/timetable/TimetableController.java @@ -15,20 +15,45 @@ import static java.util.Objects.isNull; +/** + * REST controller responsible for timetable-related endpoints. + * + *

Base request mapping is configured via the {@code apiPrefix} property: + * @RequestMapping("${apiPrefix}/timetables").

+ * + *

This controller delegates heavy-lifting to two services: + * - {@link TimetableService} for real-time or filtered timetable generation, + * - {@link TimetableCacheService} for cached timetable and auxiliary data.

+ */ @RestController @RequestMapping("${apiPrefix}/timetables") @RequiredArgsConstructor public class TimetableController { + /** + * Primary service used to fetch and filter timetables from the source. + * Use this when filtering by subgroups or applying custom subject filters. + */ private final TimetableService service; + + /** + * Cache-backed service used to return already prepared timetables and other + * inexpensive reads such as the list of timetable hours. + * + *

Prefer this service when no additional filtering is requested to reduce + * network or parsing overhead.

+ */ private final TimetableCacheService cachedService; /** - * Provide schedule of specified group and filters if all provided + * Provides the schedule for a specified general group, optionally filtered by subgroups. * - * @param generalGroupName name of general group - * @param subgroups list of subgroups - * @return schedule of specified group with provided filters - * @throws WebPageContentNotAvailableException . + * @param generalGroupName name of the general group + * @param subgroups optional list of subgroups to filter the schedule (request parameter name: "sub") + * @return timetable for the specified general group wrapped in {@link ResponseEntity} + * @throws WebPageContentNotAvailableException if the timetable page can't be fetched or parsed + * @throws SpecifiedGeneralGroupDoesntExistsException if the specified general group doesn't exist + * @throws SpecifiedSubGroupDoesntExistsException if any of the specified subgroups don't exist + * @throws JsonProcessingException if there is an error processing JSON data */ @GetMapping("/{generalGroupName}") public ResponseEntity getGeneralGroupSchedule (@PathVariable String generalGroupName, @@ -46,34 +71,51 @@ public ResponseEntity getGeneralGroupSchedule (@PathVariable Strin : ResponseEntity.ok(cachedService.getGeneralGroupSchedule(generalGroupName)); } + /** + * Provides the schedule for a specified general group, optionally filtered by subgroups and custom subjects. + * + *

If subgroups are provided, the request is forwarded to {@link TimetableService#getFilteredGeneralGroupSchedule} + * with the provided subgroups and any provided {@code customSubjects}. If {@code customSubjects} is omitted or empty + * but subgroups are present, an empty list is passed to the service to indicate "no custom subject filters".

+ * + * @param generalGroupName name of the general group + * @param subgroups optional list of subgroups to filter the schedule (request parameter name: "sub") + * @param customSubjects optional list of custom subjects to include in the schedule (request body, may be null) + * @return timetable for the specified general group wrapped in {@link ResponseEntity} + * @throws WebPageContentNotAvailableException if the timetable page can't be fetched or parsed + * @throws SpecifiedGeneralGroupDoesntExistsException if the specified general group doesn't exist + * @throws SpecifiedSubGroupDoesntExistsException if any of the specified subgroups don't exist + * @throws JsonProcessingException if there is an error processing JSON data + */ @PostMapping(value = "/{generalGroupName}", consumes = "application/json", produces = "application/json") public ResponseEntity getGeneralGroupScheduleWithCustomSubjects (@PathVariable String generalGroupName, @RequestParam(required = false, name = "sub") List subgroups, @RequestBody(required = false) List customSubjects) throws WebPageContentNotAvailableException, SpecifiedGeneralGroupDoesntExistsException, SpecifiedSubGroupDoesntExistsException, JsonProcessingException { - var areSubgroupsProvided = !(isNull(subgroups) || subgroups.isEmpty()); - var areCustomSubjectsProvided = !(isNull(customSubjects) || customSubjects.isEmpty()); + boolean hasSubgroups = subgroups != null && !subgroups.isEmpty(); - if (areSubgroupsProvided) { - if (!areCustomSubjectsProvided) { - customSubjects = new ArrayList<>(); - } - - return ResponseEntity.ok(service.getFilteredGeneralGroupSchedule( - generalGroupName, - subgroups, - customSubjects - )); - + if (!hasSubgroups) { + return ResponseEntity.ok(cachedService.getGeneralGroupSchedule(generalGroupName)); + } + + if (customSubjects == null || customSubjects.isEmpty()) { + customSubjects = new ArrayList<>(); } - return ResponseEntity.ok(cachedService.getGeneralGroupSchedule(generalGroupName)); + + return ResponseEntity.ok(service.getFilteredGeneralGroupSchedule( + generalGroupName, + subgroups, + customSubjects + )); } /** - * Provides list of schedule hours + * Returns the canonical list of timetable hour strings (e.g. "08:00-09:30"). * - * @return list of houts - * @throws WebPageContentNotAvailableException . + *

Data is returned from the cache-backed service to avoid repeated page fetches.

+ * + * @return list of timetable hours wrapped in {@link ResponseEntity} + * @throws WebPageContentNotAvailableException if the underlying source is not available */ @GetMapping("/hours") public ResponseEntity> getListOfHours () throws WebPageContentNotAvailableException { @@ -81,21 +123,27 @@ public ResponseEntity> getListOfHours () throws WebPageContentNotAv } /** - * Provides list of general groups + * Returns the list of known general groups (top-level groups). + * + *

This endpoint may trigger parsing of the timetable index if cache is not available.

* - * @return list of general groups + * @return list of general group names wrapped in {@link ResponseEntity} + * @throws WebPageContentNotAvailableException if the underlying source is not available */ @GetMapping("/groups/general") - public ResponseEntity> getListOfGeneralGroups () throws WebPageContentNotAvailableException { + public ResponseEntity> getListOfGeneralGroups () + throws WebPageContentNotAvailableException, JsonProcessingException { return ResponseEntity.ok(service.getGeneralGroupList()); } /** - * Provides list of available subgroups for specified general group + * Provides the list of available subgroups for the specified general group. * * @param generalGroupName name of general group - * @return list of available subgroups - * @throws JsonProcessingException . + * @return list of available subgroup names wrapped in {@link ResponseEntity} + * @throws JsonProcessingException if there is an error processing JSON data + * @throws SpecifiedGeneralGroupDoesntExistsException if the specified general group doesn't exist + * @throws WebPageContentNotAvailableException if the timetable page can't be fetched */ @GetMapping("/groups/{generalGroupName}") public ResponseEntity> getListOfAvailableGroups (@PathVariable String generalGroupName) @@ -103,16 +151,45 @@ public ResponseEntity> getListOfAvailableGroups (@PathVariable Stri return ResponseEntity.ok(service.getAvailableSubGroups(generalGroupName)); } + + /** + * Returns available subgroups for a specific subject within a general group. + * + *

Useful when a subject is taught only in a subset of subgroups and callers need to + * discover which subgroups contain that subject.

+ * + * @param generalGroupName general group to search in + * @param subjectName subject name for which available subgroups should be returned + * @return list of subgroup names that contain the subject wrapped in {@link ResponseEntity} + * @throws SpecifiedGeneralGroupDoesntExistsException if the specified general group doesn't exist + * @throws WebPageContentNotAvailableException if the timetable page can't be fetched + */ @GetMapping("/groups/{generalGroupName}/{subjectName}") public ResponseEntity> getListOfAvailableGroupsForSubjectName (@PathVariable String generalGroupName, @PathVariable String subjectName) - throws SpecifiedGeneralGroupDoesntExistsException, WebPageContentNotAvailableException { + throws SpecifiedGeneralGroupDoesntExistsException, WebPageContentNotAvailableException, JsonProcessingException { return ResponseEntity.ok(service.getAvailableSubGroupsForSubject(generalGroupName, subjectName)); } + /** + * Returns the list of subjects available for a given general group. + * + *

This is a convenience endpoint that does not perform network I/O if the service has cached data.

+ * + * @param generalGroupName name of the general group + * @return list of subject names wrapped in {@link ResponseEntity} + */ @GetMapping("/{generalGroupName}/list") - public ResponseEntity> getListOfSubjects (@PathVariable String generalGroupName) { + public ResponseEntity> getListOfSubjects (@PathVariable String generalGroupName) + throws JsonProcessingException { return ResponseEntity.ok(service.getListOfSubjects(generalGroupName)); } -} + @GetMapping("/{generalGroupName}/list/custom") + public ResponseEntity> getListOfCustomSubjects (@PathVariable String generalGroupName) + throws JsonProcessingException { + var response = service.getListOfCustomSubjects(generalGroupName); + return ResponseEntity.ok(response); + } + +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/timetable/TimetableExceptionHandler.java b/src/main/java/org/pkwmtt/timetable/TimetableExceptionHandler.java index 08944b3..d4192d1 100644 --- a/src/main/java/org/pkwmtt/timetable/TimetableExceptionHandler.java +++ b/src/main/java/org/pkwmtt/timetable/TimetableExceptionHandler.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.InternalException; import org.pkwmtt.exceptions.SpecifiedSubGroupDoesntExistsException; import org.pkwmtt.exceptions.dto.ErrorResponseDTO; import org.pkwmtt.exceptions.SpecifiedGeneralGroupDoesntExistsException; @@ -12,10 +13,27 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +/** + * Global exception handler for exceptions thrown from {@code TimetableController}. + *

+ * Maps specific exception types to HTTP response statuses and builds an + * {@link org.pkwmtt.exceptions.dto.ErrorResponseDTO} payload for the client. + */ @SuppressWarnings({"LoggingSimilarMessage", "StringConcatenationArgumentToLogCall"}) @Slf4j @RestControllerAdvice(assignableTypes = {TimetableController.class}) public class TimetableExceptionHandler { + /** + * Handles {@link WebPageContentNotAvailableException} thrown when the timetable + * source web page cannot be reached or its content is unavailable. + *

+ * Returns HTTP 503 (Service Unavailable) with an {@link ErrorResponseDTO} + * containing the exception message. The exception message is also logged at + * error level. + * + * @param e the thrown WebPageContentNotAvailableException + * @return a ResponseEntity containing ErrorResponseDTO and HTTP 503 status + */ @ExceptionHandler(WebPageContentNotAvailableException.class) @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) public ResponseEntity handleWebPageContentNotAvailableException ( @@ -24,26 +42,76 @@ public ResponseEntity handleWebPageContentNotAvailableExceptio return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.SERVICE_UNAVAILABLE); } + /** + * Handles {@link JsonProcessingException} which occurs during JSON parsing + * or generation within the controller processing. + *

+ * Returns HTTP 500 (Internal Server Error) with a generic {@link ErrorResponseDTO} + * message "Json Processing Failed". The underlying exception message is logged + * at error level for diagnostics. + * + * @param e the thrown JsonProcessingException + * @return a ResponseEntity containing ErrorResponseDTO and HTTP 500 status + */ @ExceptionHandler(JsonProcessingException.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public ResponseEntity handleJsonProcessingException (JsonProcessingException e) { + public ResponseEntity handleJsonProcessingException (Exception e) { log.error("INTERNAL_SERVER_ERROR # " + e.getMessage()); return new ResponseEntity<>( new ErrorResponseDTO("Json Processing Failed"), - HttpStatus.INTERNAL_SERVER_ERROR + HttpStatus.INTERNAL_SERVER_ERROR ); } + /** + * Handles client-caused errors such as: + * - {@link SpecifiedGeneralGroupDoesntExistsException} + * - {@link SpecifiedSubGroupDoesntExistsException} + * - {@link IllegalArgumentException} + *

+ * Returns HTTP 400 (Bad Request) with an {@link ErrorResponseDTO} containing + * the exception message to inform the client about invalid input or missing + * requested entities. + * + * @param e the thrown exception (one of the handled types) + * @return a ResponseEntity containing ErrorResponseDTO and HTTP 400 status + */ @ExceptionHandler({SpecifiedGeneralGroupDoesntExistsException.class, SpecifiedSubGroupDoesntExistsException.class, IllegalArgumentException.class}) @ResponseStatus(HttpStatus.BAD_REQUEST) public ResponseEntity handleSpecifiedGeneralGroupDoesntExistsException (Exception e) { return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.BAD_REQUEST); } + /** + * Handles {@link IllegalAccessException} which indicates an unexpected + * access violation during processing. + *

+ * Returns HTTP 500 (Internal Server Error) with an {@link ErrorResponseDTO} + * containing the exception message. The exception is also logged at error level. + * + * @param e the thrown IllegalAccessException + * @return a ResponseEntity containing ErrorResponseDTO and HTTP 500 status + */ @ExceptionHandler(IllegalAccessException.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ResponseEntity handleIllegalAccessException (IllegalAccessException e) { log.error("INTERNAL_SERVER_ERROR # " + e.getMessage()); return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR); } -} + + /** + * Handles {@link InternalException} for unexpected internal application errors. + * Returns HTTP 500 (Internal Server Error) with an {@link ErrorResponseDTO} + * containing the exception message. The exception is logged at error level. + * + * @param e the thrown InternalException + * @return a ResponseEntity containing ErrorResponseDTO and HTTP 500 status + */ + @ExceptionHandler(InternalException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ResponseEntity handleInternalException (InternalException e) { + log.error("INTERNAL_SERVER_ERROR # " + e.getMessage()); + return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR); + } + +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/timetable/TimetableService.java b/src/main/java/org/pkwmtt/timetable/TimetableService.java index 34350da..5e708f1 100644 --- a/src/main/java/org/pkwmtt/timetable/TimetableService.java +++ b/src/main/java/org/pkwmtt/timetable/TimetableService.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.InternalException; import org.pkwmtt.exceptions.SpecifiedGeneralGroupDoesntExistsException; import org.pkwmtt.exceptions.SpecifiedSubGroupDoesntExistsException; import org.pkwmtt.exceptions.WebPageContentNotAvailableException; @@ -21,11 +22,28 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; +/** + * Service responsible for timetable operations: + * - retrieving and caching group schedules via {@link TimetableCacheService} + * - parsing subgroup identifiers from schedule content + * - applying filters to schedules (by subgroup and custom subject filters) + *

+ * This service delegates parsing-specific logic to {@link TimetableParserService} + * and uses {@link TimetableCacheService} to fetch cached schedule data. + */ @Slf4j @Service public class TimetableService { + /** + * Cache-backed service providing group schedules and general group listings. + */ private final TimetableCacheService cachedService; + /** + * Construct a TimetableService with a {@link TimetableCacheService} dependency. + * + * @param cachedService service used to retrieve cached timetable data + */ @Autowired TimetableService (TimetableCacheService cachedService) { this.cachedService = cachedService; @@ -68,7 +86,16 @@ public List getAvailableSubGroups (String generalGroupName) return matchedGroups.stream().sorted().toList(); } - public List getAvailableSubGroupsForSubject (String generalGroupName, String subjectName) { + /** + * Search the timetable of a general group for subgroup tokens related to a specific subject name. + * Tokens include group codes and single-letter types (W, Ć, S) using a unicode-aware regex. + * + * @param generalGroupName uppercase or lowercase allowed; will be normalized + * @param subjectName name (or fragment) of the subject to search for + * @return unique list of subgroup tokens associated with the subject + */ + public List getAvailableSubGroupsForSubject (String generalGroupName, String subjectName) + throws JsonProcessingException { generalGroupName = generalGroupName.toUpperCase(); List result = new ArrayList<>(); @@ -89,6 +116,15 @@ public List getAvailableSubGroupsForSubject (String generalGroupName, St } + /** + * Checks the given subject name against the provided pattern and appends found subgroup tokens + * to the result list. Removes leading 'G' from tokens to match frontend format. + * + * @param subjectDTO subject entry to inspect + * @param subjectName subject name fragment to match against + * @param pattern compiled regex pattern for subgroup tokens + * @param result mutable list where matched tokens are appended + */ private void addMatchingSubjectGroups (SubjectDTO subjectDTO, String subjectName, Pattern pattern, @@ -106,11 +142,12 @@ private void addMatchingSubjectGroups (SubjectDTO subjectDTO, } /** - * Retrieves timetable and filters entries based on subgroups parameters + * Retrieves timetable and filters entries based on subgroups parameters and custom subject filters. * - * @param generalGroupName name of the general group - * @param subgroup subgroups list - * @return filtered timetable + * @param generalGroupName name of the general group + * @param subgroup subgroups list + * @param customSubjectFilters list of cross-group subject filters to include instead of default entries + * @return filtered timetable DTO for the requested general group * @throws WebPageContentNotAvailableException if source data can't be retrieved */ public TimetableDTO getFilteredGeneralGroupSchedule (String generalGroupName, @@ -125,102 +162,122 @@ public TimetableDTO getFilteredGeneralGroupSchedule (String generalGroupName, //Get user's schedule List schedule = cachedService.getGeneralGroupSchedule(generalGroupName).getData(); + //Go through schedule and extract customSubject details - List customSubjectsDetails = - createListOfCustomSchedulesDetails(generalGroupName, customSubjectFilters, schedule); + List customSubjectsDetails = createListOfCustomSchedulesDetails( + generalGroupName, customSubjectFilters, schedule); return filterSchedule(schedule, subgroup, generalGroupName, customSubjectsDetails); } + /** + * Build a flattened list of {@link CustomSubjectDetails} for all provided custom filters. + * Each entry contains the subject instance, subgroup token, day index and week type. + * + * @param generalGroupName name of the main group (used to decide whether to reuse the provided schedule) + * @param customSubjectFilters filters describing subjects to pull from possibly other groups + * @param schedule schedule of the primary general group (reused when applicable) + * @return list of custom subject details matching the provided filters + */ private List createListOfCustomSchedulesDetails (String generalGroupName, List customSubjectFilters, List schedule) { List customSubjectsDetails = new ArrayList<>(); customSubjectFilters.forEach(customFilter -> { - //Get schedule for specified filter - List customSubjectSchedule = customFilter - .getGeneralGroup() - .equals(generalGroupName) ? schedule : cachedService - .getGeneralGroupSchedule(customFilter.getGeneralGroup()) - .getData(); + List customSubjectSchedule = schedule; + if (!customFilter.generalGroup().equals(generalGroupName)) { + try { + customSubjectSchedule = cachedService + .getGeneralGroupSchedule(customFilter.generalGroup()) + .getData(); + } catch (JsonProcessingException e) { + throw new InternalException(e); + } + } - //Add detail like classroom and rowId - //Go by days: Monday, Tuesday etc... for (int i = 0; i < customSubjectSchedule.size(); i++) { - //Find subjects matching filters customSubjectsDetails.addAll( searchDayOfWeekAndAddCustomSubjectsDetails( - customSubjectSchedule.get(i).getEven(), customFilter, i, - TypeOfWeek.EVEN - )); + customSubjectSchedule.get(i).getEven(), customFilter, i, TypeOfWeek.EVEN)); customSubjectsDetails.addAll( searchDayOfWeekAndAddCustomSubjectsDetails( - customSubjectSchedule.get(i).getOdd(), customFilter, i, - TypeOfWeek.ODD - )); + customSubjectSchedule.get(i).getOdd(), customFilter, i, TypeOfWeek.ODD)); } }); return customSubjectsDetails; } + /** + * Search a day (even/odd) for subjects matching the custom filter and convert matches into + * {@link CustomSubjectDetails}. + *

+ * The matching behavior depends on the parsed subject type: + * - For EXERCISES, LECTURE, SEMINAR: match by name and by parsed type + * - Default: match by name and subgroup token (e.g. K01) + * + * @param day list of subjects for the specific day and parity + * @param customFilter filter describing the desired subject and subgroup + * @param dayIndex index of the day in the week (0-based) + * @param typeOfWeek parity of the week (EVEN / ODD) + * @return list of matched custom subject details for that day segment + */ private List searchDayOfWeekAndAddCustomSubjectsDetails (List day, CustomSubjectFilterDTO customFilter, int dayIndex, TypeOfWeek typeOfWeek) { List matches = switch (TimetableParserService.extractSubjectTypeFromName( - customFilter.getSubGroup())) { - //Filter by matching name and subgroup from customFilter - //If exercises,lecture or seminar just compare type of subject + + customFilter.subGroup())) { case EXERCISES, LECTURE, SEMINAR -> day .stream() - .filter(item -> (item - .getName() - .contains(customFilter.getName()) && - TimetableParserService - .extractSubjectTypeFromName(item.getName()) - .equals( - TimetableParserService - .extractSubjectTypeFromName(customFilter.getSubGroup())) - )) + .filter(item -> (item.getName().contains(customFilter.name()) && TimetableParserService + .extractSubjectTypeFromName(item.getName()) + .equals(TimetableParserService.extractSubjectTypeFromName(customFilter.subGroup())))) .toList(); - //Filter by matching name and subgroup from customFilter - //if LKP groups compare group type and number default -> day .stream() - .filter(item -> - (item - .getName() - .contains(customFilter.getName()) && - item - .getName() - .contains(customFilter.getSubGroup()))).toList(); + .filter(item -> (item.getName().contains(customFilter.name()) && item + .getName() + .contains(customFilter.subGroup()))) + .toList(); }; if (!matches.isEmpty()) { return matches .stream() - .map((item) -> new CustomSubjectDetails(item, customFilter.getSubGroup(), dayIndex, typeOfWeek)) + .map((item) -> new CustomSubjectDetails(item, customFilter.subGroup(), dayIndex, typeOfWeek)) .toList(); } return new ArrayList<>(); } + /** + * Apply subgroup and custom subject filters to a week's schedule and return a new {@link TimetableDTO}. + *

+ * Steps: + * - For each day remove entries that collide with custom filters + * - Filter remaining entries by requested subgroup tokens (including derived W/Ć/S tokens) + * - Strip subject type markers from names before returning + * + * @param schedule mutable list representing days of week to filter + * @param subgroups requested subgroup tokens to keep + * @param generalGroupName name of the group to populate result DTO + * @param customSubjectsDetails list of custom subject replacements to apply + * @return new TimetableDTO containing the filtered schedule + */ private TimetableDTO filterSchedule (List schedule, List subgroups, String generalGroupName, List customSubjectsDetails) { - //Go through user's schedule day by day for (int i = 0; i < schedule.size(); i++) { var day = schedule.get(i); deleteSubjectsCollidingWithCustomFilters(customSubjectsDetails, day); - //Filter by user's subgroups - filterDayBySubgroupsWithSeminarsExercisesAndLectures( - subgroups, customSubjectsDetails, day, i); + filterDayBySubgroupsWithSeminarsExercisesAndLectures(subgroups, customSubjectsDetails, day, i); } schedule.forEach(DayOfWeekDTO::deleteSubjectTypesFromNames); @@ -228,9 +285,22 @@ private TimetableDTO filterSchedule (List schedule, return new TimetableDTO(generalGroupName, schedule); } + /** + * Filters a single day by provided subgroup tokens while respecting custom subject details. + *

+ * If no custom subjects are present the day is filtered directly by each subgroup. + * Otherwise a per-subgroup filtered view is produced considering only custom subjects that + * match the day and subgroup token. + * + * @param subgroups list of subgroup tokens (e.g. K01, P02, W, Ć, S) + * @param customSubjectsDetails precomputed custom subject details to consider during filtering + * @param day day-of-week DTO to mutate + * @param dayIndex index of the day within the week (0-based) + */ private void filterDayByUsersSubgroups (List subgroups, List customSubjectsDetails, - DayOfWeekDTO day, int dayIndex) { + DayOfWeekDTO day, + int dayIndex) { subgroups.forEach(subgroup -> { if (customSubjectsDetails.isEmpty()) { day.filterByGroup(subgroup); @@ -248,22 +318,32 @@ private void filterDayByUsersSubgroups (List subgroups, }); } + /** + * Extends provided subgroup list with single-letter subject-type tokens derived from custom subjects + * (W for lecture, Ć for exercises, S for seminar) then delegates to {@link #filterDayByUsersSubgroups(List, List, DayOfWeekDTO, int)}. + * + * @param subgroups base subgroup tokens requested by the user + * @param customSubjectsDetails list of custom subject details used to derive W/Ć/S tokens + * @param day day DTO to filter + * @param dayIndex index of the day + */ private void filterDayBySubgroupsWithSeminarsExercisesAndLectures (List subgroups, List customSubjectsDetails, - DayOfWeekDTO day, int dayIndex) { - - Set SCWgroups = new HashSet<>( - customSubjectsDetails.stream().map(CustomSubjectDetails::getSubGroup) - .map(item -> - switch (TimetableParserService.extractSubjectTypeFromName(item)) { - case SEMINAR -> "S"; - case EXERCISES -> "Ć"; - case LECTURE -> "W"; - default -> null; - } - ) - .filter(Objects::nonNull) - .toList()); + DayOfWeekDTO day, + int dayIndex) { + Set SCWgroups = new HashSet<>(customSubjectsDetails + .stream() + .map(CustomSubjectDetails::getSubGroup) + .map( + item -> switch (TimetableParserService.extractSubjectTypeFromName( + item)) { + case SEMINAR -> "S"; + case EXERCISES -> "Ć"; + case LECTURE -> "W"; + default -> null; + }) + .filter(Objects::nonNull) + .toList()); List effectiveSubgroups = new ArrayList<>(subgroups); effectiveSubgroups.addAll(SCWgroups); @@ -272,33 +352,48 @@ private void filterDayBySubgroupsWithSeminarsExercisesAndLectures (List } + /** + * Remove subjects from the provided day that conflict with any custom subject detail. + *

+ * A subject is considered colliding when: + * - the base name (after deleting type markers) matches the custom subject's name AND + * - both have the same parsed subject type (lecture/exercises/seminar) + * + * @param customSubjectsDetails list of custom subjects to consider (these are used to remove original entries) + * @param day day DTO to mutate by removing colliding subjects + */ private void deleteSubjectsCollidingWithCustomFilters (List customSubjectsDetails, DayOfWeekDTO day) { for (CustomSubjectDetails customSubjectDetail : customSubjectsDetails) { customSubjectDetail.getSubject().deleteTypeAndUnnecessaryCharactersFromName(); - - day.setEven( - day - .getEven() - .stream() - .filter( - subject -> !(subject - .getName() - .contains(customSubjectDetail.getSubject().getName()) - && subjectsAreSameType(subject, customSubjectDetail)) - ).toList()); + day.setEven(day + .getEven() + .stream() + .filter(subject -> !(subject + .getName() + .contains(customSubjectDetail.getSubject().getName()) && subjectsAreSameType( + subject, customSubjectDetail))) + .toList()); day.setOdd(day .getOdd() .stream() - .filter( - subject -> !(subject.getName().contains(customSubjectDetail.getSubject().getName()) - && subjectsAreSameType(subject, customSubjectDetail)) - ).toList()); + .filter(subject -> !(subject + .getName() + .contains(customSubjectDetail.getSubject().getName()) && subjectsAreSameType( + subject, customSubjectDetail))) + .toList()); } } + /** + * Compare parsed subject types for two sources: an existing subject and a custom subject detail. + * + * @param subject subject from the day schedule + * @param customSubjectDetails custom subject descriptor containing subgroup token for type extraction + * @return true when both parsed types are equal + */ private boolean subjectsAreSameType (SubjectDTO subject, CustomSubjectDetails customSubjectDetails) { var subjectType = TimetableParserService.extractSubjectTypeFromName(subject.getName()); var customSubjectType = TimetableParserService.extractSubjectTypeFromName( @@ -307,6 +402,14 @@ private boolean subjectsAreSameType (SubjectDTO subject, CustomSubjectDetails cu } + /** + * Validate that all requested subgroup tokens exist for the given general group. + * + * @param generalGroupName name of a general group + * @param subgroup list of subgroup tokens to validate + * @throws JsonProcessingException when available subgroup extraction fails + * @throws SpecifiedSubGroupDoesntExistsException when any requested subgroup is not present + */ private void checkSubGroupAvailability (String generalGroupName, List subgroup) throws JsonProcessingException { //Check if specified subgroup is available for this generalGroup @@ -318,11 +421,25 @@ private void checkSubGroupAvailability (String generalGroupName, List su } } - public List getGeneralGroupList () throws WebPageContentNotAvailableException { + /** + * Return an alphabetically sorted list of all known general groups. + * + * @return sorted list of general group names + * @throws WebPageContentNotAvailableException when the underlying cache cannot provide the map + */ + public List getGeneralGroupList () + throws WebPageContentNotAvailableException, JsonProcessingException { return cachedService.getGeneralGroupsMap().keySet().stream().sorted().collect(Collectors.toList()); } - public List getListOfSubjects (String generalGroupName) { + /** + * Collect list of distinct subject names found in the schedule for a given general group. + * Subject names are normalized by deleting type markers and unnecessary characters. + * + * @param generalGroupName group whose schedule will be scanned + * @return unique list of normalized subject names + */ + public List getListOfSubjects (String generalGroupName) throws JsonProcessingException { var subjectSet = new HashSet(); var schedule = cachedService.getGeneralGroupSchedule(generalGroupName); @@ -334,9 +451,37 @@ public List getListOfSubjects (String generalGroupName) { return subjectSet.stream().toList(); } + /** + * Return a filtered list of custom subject names for the specified general group. + * + *

This method collects normalized subject names from the group's schedule and + * returns all except those that match a small set of predefined cross-group/custom subjects + * (currently: "niemiecki", "J ang", "WF hala"). The exclusion is case-sensitive + * and relies on the normalization performed by {@link #getListOfSubjects(String)}. + * + * @param generalGroupName group whose schedule will be scanned + * @return list of matching custom subject names + * @throws JsonProcessingException when timetable parsing or retrieval fails + */ + public List getListOfCustomSubjects (String generalGroupName) throws JsonProcessingException { + return getListOfSubjects(generalGroupName).stream().filter( + subject -> !( + subject.contains("niemiecki") || + subject.contains("J ang") || + subject.contains("WF hala") + ) + ).toList(); + } + + /** + * Normalize a subject by removing type markers and add its name to the provided set. + * + * @param subjectSet destination set collecting subject names + * @param subject subject instance to normalize and add + */ private void addToSet (Set subjectSet, SubjectDTO subject) { subject.deleteTypeAndUnnecessaryCharactersFromName(); subjectSet.add(subject.getName()); } -} +} \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/timetable/dto/CustomSubjectFilterDTO.java b/src/main/java/org/pkwmtt/timetable/dto/CustomSubjectFilterDTO.java index e024dc7..5c2e283 100644 --- a/src/main/java/org/pkwmtt/timetable/dto/CustomSubjectFilterDTO.java +++ b/src/main/java/org/pkwmtt/timetable/dto/CustomSubjectFilterDTO.java @@ -1,14 +1,8 @@ package org.pkwmtt.timetable.dto; -import lombok.Data; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Data -@RequiredArgsConstructor -@Getter -public class CustomSubjectFilterDTO { - private final String name; - private final String generalGroup; - private final String subGroup; +/** + * Data Transfer Object (DTO) representing a custom subject filter. + * This class contains the name of the subject, its general group, and its sub-group. + */ +public record CustomSubjectFilterDTO(String name, String generalGroup, String subGroup) { } diff --git a/src/main/java/org/pkwmtt/timetable/dto/DayOfWeekDTO.java b/src/main/java/org/pkwmtt/timetable/dto/DayOfWeekDTO.java index 280cd42..47ca2c1 100644 --- a/src/main/java/org/pkwmtt/timetable/dto/DayOfWeekDTO.java +++ b/src/main/java/org/pkwmtt/timetable/dto/DayOfWeekDTO.java @@ -13,16 +13,29 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; - +/** + * Data Transfer Object (DTO) representing a day of the week with its associated subjects. + * This class contains the name of the day and two lists of subjects: + * one for odd weeks and another for even weeks. + */ @Slf4j @Data public class DayOfWeekDTO { + /** The name of the day of the week (e.g., "Monday", "Tuesday"). */ private final String name; + /** List of subjects scheduled for odd weeks. */ @Setter private List odd; + /** List of subjects scheduled for even weeks. */ @Setter private List even; + /** + * Constructs a DayOfWeekDTO with the specified name. + * Initializes the lists for odd and even week subjects as empty lists. + * + * @param name the name of the day of the week + */ public DayOfWeekDTO (String name) { this.name = name; odd = new ArrayList<>(); @@ -30,31 +43,40 @@ public DayOfWeekDTO (String name) { } /** - * Add subject by week Type + * Adds a subject to the appropriate list (odd, even, or both) based on the specified type of week. * - * @param subjectDTO - subject - * @param typeOfWeek - type of week + * @param subjectDTO the subject to be added, represented as a `SubjectDTO` object + * @param typeOfWeek the type of week (EVEN, ODD, or BOTH) indicating where the subject should be added */ public void add (SubjectDTO subjectDTO, TypeOfWeek typeOfWeek) { switch (typeOfWeek) { - case EVEN -> this.even.add(subjectDTO); - case ODD -> this.odd.add(subjectDTO); + case EVEN -> this.even.add(subjectDTO); // Add to the even-week list + case ODD -> this.odd.add(subjectDTO); // Add to the odd-week list + case BOTH -> { // Add to both odd- and even-week lists + this.even.add(subjectDTO); + this.odd.add(subjectDTO); + } } } + /** + * Removes unnecessary characters and type information from the names + * of all subjects in both the odd- and even-week lists. + * This operation is performed by invoking the `deleteTypeAndUnnecessaryCharactersFromName` + * method on each `SubjectDTO` in the respective lists. + */ public void deleteSubjectTypesFromNames () { even.forEach(SubjectDTO::deleteTypeAndUnnecessaryCharactersFromName); odd.forEach(SubjectDTO::deleteTypeAndUnnecessaryCharactersFromName); } + /** - * Filters both odd- and even-week subject lists, - * keeping only those entries that belong exclusively - * to the specified group code. + * Filters the subjects in both the odd- and even-week lists based on the specified group. + * The filtering is performed by extracting the group character and target number + * from the provided group string and applying the filter to each list. * - * @param group the full group identifier (e.g., "K03"), - * where the first character is the group letter - * and the last character is the subgroup number + * @param group the group identifier (e.g., "K03") used to filter the subjects */ public void filterByGroup (String group) { var groupCharAndTargetNumber = getGroupCharAndTargetNumber(group); @@ -62,12 +84,20 @@ public void filterByGroup (String group) { // Apply the filter to both odd- and even-week lists odd = filter(odd, groupCharAndTargetNumber.getFirst(), groupCharAndTargetNumber.getSecond()); even = filter(even, groupCharAndTargetNumber.getFirst(), groupCharAndTargetNumber.getSecond()); - } + /** + * Filters the subjects in both the odd- and even-week lists based on the specified subgroup + * and a list of custom subjects. The filtering is performed by extracting the group character + * and target number from the provided subgroup string and applying the filter to each list. + * Custom subjects are filtered by their type of week (ODD or EVEN) and included in the respective lists. + * + * @param subGroup the subgroup identifier (e.g., "K03") used to filter the subjects + * @param customSubjects a list of custom subjects to be included in the filtering process + */ public void filterByGroup (String subGroup, List customSubjects) { var groupCharAndTargetNumber = getGroupCharAndTargetNumber(subGroup); - + // Apply the filter to both odd- and even-week lists odd = filter( odd, groupCharAndTargetNumber.getFirst(), groupCharAndTargetNumber.getSecond(), customSubjects @@ -82,9 +112,16 @@ public void filterByGroup (String subGroup, List customSub .filter(customSubject -> customSubject.getTypeOfWeek().equals(TypeOfWeek.EVEN)) .toList() ); - } + /** + * Extracts the group character and target number from the provided group string. + * If the group string starts with 'G' and its length is greater than 3, the first character is removed. + * The group character (e.g., "K" from "K03") and the subgroup digit (e.g., "3" from "K03") are then extracted. + * + * @param group the group string (e.g., "K03" or "GK03") to process + * @return a Pair containing the group character as the first element and the subgroup digit as the second element + */ private Pair getGroupCharAndTargetNumber (String group) { // Delete first character if group starts 'G' if (group.charAt(0) == 'G' && group.length() > 3) { @@ -112,24 +149,26 @@ private List filter (List list, String groupName, String return list.stream().filter(item -> hasOnlyTargetGroup(item.getName(), groupName, targetNumber)).toList(); } + /** - * Filter by subgroup char and number + * Filters a list of `SubjectDTO` objects based on the specified group name, target number, + * and a list of custom subjects. The method first filters the list to include only items + * that match the target group and subgroup. Then, it adds custom subjects to the list, + * marks them as custom, and sorts the final list by the row ID. * - * @param list list of subjects for specific day - * @param groupName - name of subgroup - * @param targetNumber - number fo subgroup - * @param customSubjects - custom subjects added by user - * @return modified list of subjects + * @param list the original list of `SubjectDTO` objects to be filtered + * @param groupName the group name (e.g., "K") used for filtering + * @param targetNumber the subgroup number (e.g., "4") used for filtering + * @param customSubjects a list of `CustomSubjectDetails` to be added to the filtered list + * @return a filtered and sorted list of `SubjectDTO` objects */ private List filter (List list, String groupName, String targetNumber, List customSubjects) { - - list = list .stream() - .filter(item -> hasOnlyTargetGroup(item.getName(), groupName, targetNumber)) // K04 -> usun K != 4 + .filter(item -> hasOnlyTargetGroup(item.getName(), groupName, targetNumber)) .collect(Collectors.toList()); for (var customSubject : customSubjects) { @@ -141,13 +180,16 @@ private List filter (List list, return list; } + /** - * Checks if the given element string contains no other codes for the same group.* + * Checks if the given element matches only the specified target group and subgroup number. + * The method first verifies if the element does not belong to any group other than the target group. + * If the element belongs to the target group, it further checks if the subgroup number matches the target number. * - * @param element the subject type string (e.g., "Mechatronika K03") - * @param groupName the group letter (e.g., "K") - * @param targetNumber the digit we want to allow (e.g., "3") - * @return true if no non-target subgroup codes are present + * @param element the string to be checked, representing the group and subgroup information + * @param groupName the name of the target group (e.g., "K") + * @param targetNumber the target subgroup number (e.g., "3") + * @return true if the element matches only the target group and subgroup number, false otherwise */ private boolean hasOnlyTargetGroup (String element, String groupName, String targetNumber) { var pattern = Pattern.compile(String.format("\\bG?[%s]0[1-9]\\b", Pattern.quote(groupName))); diff --git a/src/main/java/org/pkwmtt/timetable/dto/SubjectDTO.java b/src/main/java/org/pkwmtt/timetable/dto/SubjectDTO.java index 97f7504..6a54eb9 100644 --- a/src/main/java/org/pkwmtt/timetable/dto/SubjectDTO.java +++ b/src/main/java/org/pkwmtt/timetable/dto/SubjectDTO.java @@ -2,19 +2,54 @@ import lombok.*; import lombok.experimental.Accessors; -import org.pkwmtt.examCalendar.enums.SubjectType; +import org.pkwmtt.calendar.exams.enums.SubjectType; import java.util.regex.Pattern; +/** + * Data transfer object representing a subject in the timetable. + *

+ * Contains basic display and import-related fields and utility methods for + * cleaning up subject names imported from external sources. + */ @Data @Accessors(chain = true) public class SubjectDTO { + /** + * Subject name (may contain type suffixes or extraneous characters). + */ private String name; + /** + * Classroom identifier where the subject is held. + */ private String classroom; + /** + * Row id from the source (e.g., spreadsheet or CSV) used for tracking. + */ private int rowId; + /** + * Type of the subject. + */ private SubjectType type; + /** + * Flag indicating whether the subject is a custom entry (not from the standard set). + */ private Boolean custom = false; + /** + * Cleans the {@code name} field by: + * - Trimming to the first token (text before the first space). + * - Replacing underscores with spaces. + * - Removing opening and closing parentheses. + *

+ * Examples: + * - "Math (Lecture)" -> "Math" + * - "Computer_Science (Lab)" -> "Computer Science" + *

+ * This method mutates the {@code name} field. It does not perform a null + * check on {@code name}; callers should ensure {@code name} is not null + * before invoking this method. + */ public void deleteTypeAndUnnecessaryCharactersFromName () { if (name.contains(" ")) { this.name = name.substring(0, name.indexOf(' ')); diff --git a/src/main/java/org/pkwmtt/timetable/dto/TimetableDTO.java b/src/main/java/org/pkwmtt/timetable/dto/TimetableDTO.java index fd011da..d89a275 100644 --- a/src/main/java/org/pkwmtt/timetable/dto/TimetableDTO.java +++ b/src/main/java/org/pkwmtt/timetable/dto/TimetableDTO.java @@ -4,17 +4,34 @@ import java.util.List; + +/** + * Data Transfer Object (DTO) representing a timetable. + * This class contains the name of the timetable and a list of days of the week, + * each represented by a DayOfWeekDTO. + */ @Getter @Setter @AllArgsConstructor @NoArgsConstructor public class TimetableDTO { + /** + * The name of the timetable. + */ private String name; + /** + * List of days of the week in the timetable. + */ private List data; - - public TimetableDTO(String name) { + + /** + * Constructs a TimetableDTO with the specified name. + * + * @param name the name of the timetable + */ + public TimetableDTO (String name) { this.name = name; } - - + + } diff --git a/src/main/java/org/pkwmtt/timetable/enums/TypeOfWeek.java b/src/main/java/org/pkwmtt/timetable/enums/TypeOfWeek.java index e09ea53..212da97 100644 --- a/src/main/java/org/pkwmtt/timetable/enums/TypeOfWeek.java +++ b/src/main/java/org/pkwmtt/timetable/enums/TypeOfWeek.java @@ -1,5 +1,13 @@ package org.pkwmtt.timetable.enums; +/** + * Represents the type of week for scheduling purposes. + * This enum defines three possible values: + * - ODD: Represents odd weeks. + * - EVEN: Represents even weeks. + * - BOTH: Represents both odd and even weeks. + */ public enum TypeOfWeek { ODD, EVEN, BOTH } + diff --git a/src/main/java/org/pkwmtt/timetable/objects/CustomSubjectDetails.java b/src/main/java/org/pkwmtt/timetable/objects/CustomSubjectDetails.java index 588e91a..d191d50 100644 --- a/src/main/java/org/pkwmtt/timetable/objects/CustomSubjectDetails.java +++ b/src/main/java/org/pkwmtt/timetable/objects/CustomSubjectDetails.java @@ -7,14 +7,35 @@ import org.pkwmtt.timetable.dto.SubjectDTO; import org.pkwmtt.timetable.enums.TypeOfWeek; +/** + * Class representing custom details of a subject. + * It includes the subject information, sub-group, day of the week number, and type of week. + */ @Getter @AllArgsConstructor public class CustomSubjectDetails { + /** + * The subject information. + */ SubjectDTO subject; + /** + * The sub-group of the subject. + */ String subGroup; + /** + * The day of the week number (e.g., 1 for Monday, 2 for Tuesday). + */ int dayOfWeekNumber; + /** + * The type of week (ODD, EVEN, or BOTH). + */ TypeOfWeek typeOfWeek; + /** + * Returns a JSON string representation of the CustomSubjectDetails object. + * + * @return JSON string representation of the object + */ @Override public String toString () { JsonMapper mapper = new JsonMapper(); diff --git a/src/main/java/org/pkwmtt/timetable/parser/TimetableParserService.java b/src/main/java/org/pkwmtt/timetable/parser/TimetableParserService.java index cb32fbb..e9a2bb4 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.examCalendar.enums.SubjectType; +import org.pkwmtt.calendar.exams.enums.SubjectType; import org.pkwmtt.timetable.enums.TypeOfWeek; import org.springframework.stereotype.Service; @@ -108,15 +108,25 @@ public List parse (String html) { //Go every item in column for (int itemId = 0; itemId < items.size() - 1; itemId += 2) { - boolean notOdd; String name = items.get(itemId).text(); String classroom = items.get(itemId + 1).text(); - notOdd = isNameNotOdd(name); - SubjectDTO subject = buildSubject(name, classroom, rowId); - days.get(columnId).add(subject, notOdd ? TypeOfWeek.EVEN : TypeOfWeek.ODD); + TypeOfWeek typeOfWeek; + + boolean notOdd = isNameNotOdd(name); + boolean notEven = isNameNotEven(name); + + if (notOdd == notEven) { + typeOfWeek = TypeOfWeek.BOTH; + } else if (notOdd) { + typeOfWeek = TypeOfWeek.EVEN; + } else { + typeOfWeek = TypeOfWeek.ODD; + } + + days.get(columnId).add(subject, typeOfWeek); } } } @@ -291,4 +301,8 @@ private String deleteOddMark (String text) { private boolean isNameNotOdd (String name) { return !name.contains("(N") && !name.contains("-(n"); } + + private boolean isNameNotEven (String name) { + return !name.contains("(P") && !name.contains("-(p"); + } } \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/utils/UtilsProperty.java b/src/main/java/org/pkwmtt/utils/UtilsProperty.java new file mode 100644 index 0000000..ab5fadb --- /dev/null +++ b/src/main/java/org/pkwmtt/utils/UtilsProperty.java @@ -0,0 +1,38 @@ +package org.pkwmtt.utils; + +import jakarta.persistence.*; +import lombok.*; +import java.time.Instant; + +@Entity +@Table(name = "utils_kv") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class UtilsProperty { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "property_key", nullable = false, unique = true, length = 191) + private String key; + + @Column(name = "property_value", columnDefinition = "VARCHAR(250)") + private String value; + + @Column(name = "value_type") + private String type; + + @Column(name = "updated_at") + private Instant updatedAt; + + public UtilsProperty(String key, String value, String type) { + this.key = key; + this.value = value; + this.type = type; + this.updatedAt = Instant.now(); + } +} + diff --git a/src/main/java/org/pkwmtt/utils/UtilsRepository.java b/src/main/java/org/pkwmtt/utils/UtilsRepository.java new file mode 100644 index 0000000..6e4187f --- /dev/null +++ b/src/main/java/org/pkwmtt/utils/UtilsRepository.java @@ -0,0 +1,10 @@ +package org.pkwmtt.utils; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UtilsRepository extends JpaRepository { + Optional findByKey(String key); +} + diff --git a/src/main/java/org/pkwmtt/utils/UtilsService.java b/src/main/java/org/pkwmtt/utils/UtilsService.java new file mode 100644 index 0000000..38e5d2b --- /dev/null +++ b/src/main/java/org/pkwmtt/utils/UtilsService.java @@ -0,0 +1,74 @@ +package org.pkwmtt.utils; + +import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.Optional; + +@Service +@Slf4j +public class UtilsService { + + private final UtilsRepository repository; + private final Cache cache; + + @Autowired + public UtilsService (UtilsRepository repository, CacheManager cacheManager) { + this.repository = repository; + this.cache = cacheManager.getCache("utils"); + } + + public Optional getEndOfSemester () { + String key = "endOfSemester"; + log.debug("Loading endOfSemester from cache/DB"); + + // Load string value from cache or DB if missing + String val = cache.get(key, () -> repository.findByKey(key).map(UtilsProperty::getValue).orElse(null)); + + if (val == null) { + return Optional.empty(); + } + + try { + return Optional.of(LocalDate.parse(val)); + } catch (Exception ex) { + // corrupted data -> evict cache entry so next read will reload from DB (and log) + cache.evict(key); + log.warn("Failed to parse endOfSemester value='{}'", val, ex); + return Optional.empty(); + } + } + + @Transactional + public LocalDate setEndOfSemester (LocalDate date) { + String key = "endOfSemester"; + UtilsProperty prop = repository.findByKey(key) + .orElseGet(() -> new UtilsProperty(key, null, "date")); + prop.setValue(date.toString()); + prop.setType("date"); + repository.save(prop); + + // update cache so readers get fresh value + if (cache != null) { + cache.put(key, date.toString()); + } + + log.info("endOfSemester set to {}", date); + return date; + } + + @Transactional + public void removeEndOfSemester () { + String key = "endOfSemester"; + repository.findByKey(key).ifPresent(repository::delete); + if (cache != null) { + cache.evict(key); + } + log.info("endOfSemester removed from DB and cache evicted"); + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index b860289..3781701 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -1,8 +1,10 @@ - - + + + + - - - logs/app.log + + + ${LOG_PATH} true + + + + ${LOG_DIR}/app.%d{yyyy-MM-dd}.log.gz + 30 + + + + %d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n + ERROR diff --git a/src/test/java/org/pkwmtt/ValuesForTest.java b/src/test/java/org/pkwmtt/ValuesForTest.java index dd5e92b..c0be772 100644 --- a/src/test/java/org/pkwmtt/ValuesForTest.java +++ b/src/test/java/org/pkwmtt/ValuesForTest.java @@ -111,7 +111,7 @@ public interface ValuesForTest { 9 14:30-15:15 - PKM K04-(N. #Pkm A227-n + WF (M) Ć-M-(N. W1 Hala2 PPSystM K04-(n. #Psm J209-n
PPSystM K04-(p. #PSm J209-p   WspInfPM P04-(N) #Wpm A338-n
PSieciKP W-(P) #PKP A437-p @@ -129,7 +129,7 @@ public interface ValuesForTest { 11 16:15-17:00 - PrSteroP L04-(N. #Psp G107-n
PrSteroP L04-(P. #psP G107-p + J niemiecki-(N)-(N. #HE1 A307-n
PrSteroP L04-(P. #psP G107-p PPSystM K01-(n. GF J207.1-n
PPSystM K01-(p. FG J207.1-p   PPSystM W-(N) #PSM G18-n
PAplikInt W-(P) #PAI G18-p @@ -157,7 +157,7 @@ public interface ValuesForTest { 14 18:45-19:30 SocPsychP Ć-(N) JJ A409-n - BazDan K01-(N) PB G117-n
BazDan K01-(P) BP G117-p + J angielski-(n. #WG4 J204-n
J angielski-(n. #WG4 J204-n termin dodatkowy Katedra M7 termin dodatkowy Katedra M7   diff --git a/src/test/java/org/pkwmtt/cache/CacheConfigTest.java b/src/test/java/org/pkwmtt/cache/CacheConfigTest.java index b983b42..928bda9 100644 --- a/src/test/java/org/pkwmtt/cache/CacheConfigTest.java +++ b/src/test/java/org/pkwmtt/cache/CacheConfigTest.java @@ -1,5 +1,6 @@ package org.pkwmtt.cache; +import com.fasterxml.jackson.core.JsonProcessingException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -41,7 +42,7 @@ public void initWireMock () { } @Test - void testCacheKeyPresent_Schedule () { + void testCacheKeyPresent_Schedule () throws JsonProcessingException { //given //when diff --git a/src/test/java/org/pkwmtt/cache/CacheInspector.java b/src/test/java/org/pkwmtt/cache/CacheInspector.java index fdf4749..e462644 100644 --- a/src/test/java/org/pkwmtt/cache/CacheInspector.java +++ b/src/test/java/org/pkwmtt/cache/CacheInspector.java @@ -1,5 +1,6 @@ package org.pkwmtt.cache; +import com.fasterxml.jackson.core.JsonProcessingException; import com.github.benmanes.caffeine.cache.Cache; import lombok.RequiredArgsConstructor; import org.pkwmtt.timetable.TimetableCacheService; @@ -33,7 +34,7 @@ public Map getAllEntries (String cacheName) { return nativeCache.asMap(); } - public String printAllEntries (String cacheName) { + public String printAllEntries (String cacheName) throws JsonProcessingException { service.getListOfHours(); service.getGeneralGroupSchedule("12K1"); service.getGeneralGroupsMap(); diff --git a/src/test/java/org/pkwmtt/calendar/events/services/EventsServiceTest.java b/src/test/java/org/pkwmtt/calendar/events/services/EventsServiceTest.java new file mode 100644 index 0000000..af6e84b --- /dev/null +++ b/src/test/java/org/pkwmtt/calendar/events/services/EventsServiceTest.java @@ -0,0 +1,148 @@ +package org.pkwmtt.calendar.events.services; + +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.calendar.enities.SuperiorGroup; +import org.pkwmtt.calendar.events.dto.EventDTO; +import org.pkwmtt.calendar.events.entities.Event; +import org.pkwmtt.calendar.events.entities.EventType; +import org.pkwmtt.calendar.events.repositories.EventsRepository; +import org.pkwmtt.calendar.events.repositories.EventTypeRepository; + +import java.lang.reflect.Field; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class EventsServiceTest { + + @Mock + private EventsRepository eventsRepository; + + @Mock + private EventTypeRepository eventTypeRepository; + + @InjectMocks + private EventsService eventsService; + + @Test + void getAllEventsReturnsMappedDTOs () { + // given + Date start = new Date(System.currentTimeMillis() + 1_000_000); + Date end = new Date(System.currentTimeMillis() + 2_000_000); + EventType type = new EventType(1, "Meeting"); + SuperiorGroup g1 = SuperiorGroup.builder().name("12K").build(); + Event e1 = new Event(11, "t1", "d1", start, end, type, List.of(g1)); + Event e2 = new Event(12, "t2", "d2", start, end, type, List.of(g1)); + + when(eventsRepository.findAll()).thenReturn(List.of(e1, e2)); + + // when + var dtos = eventsService.getAllEvents(); + + // then + assertNotNull(dtos); + assertEquals(2, dtos.size()); + assertEquals("t1", dtos.getFirst().getTitle()); + assertEquals("Meeting", dtos.getFirst().getType()); + assertEquals(List.of("12K"), dtos.getFirst().getSuperiorGroups()); + } + + @Test + void getEventsForSuperiorGroupIsCaseInsensitiveAndFiltersCorrectly () { + // given + Date start = new Date(System.currentTimeMillis() + 1_000_000); + Date end = new Date(System.currentTimeMillis() + 2_000_000); + EventType type = new EventType(1, "TypeA"); + SuperiorGroup g1 = SuperiorGroup.builder().name("12K").build(); + SuperiorGroup g2 = SuperiorGroup.builder().name("34B").build(); + Event match = new Event(21, "match", "d", start, end, type, List.of(g1)); + Event other = new Event(22, "other", "d", start, end, type, List.of(g2)); + + when(eventsRepository.findAll()).thenReturn(List.of(match, other)); + + // when + var result = eventsService.getEventsForSuperiorGroup("12k"); // lower-case on purpose + + // then + assertEquals(1, result.size()); + assertEquals("match", result.getFirst().getTitle()); + assertEquals(List.of("12K"), result.getFirst().getSuperiorGroups()); + } + + @Test + void getEventsForSuperiorGroupReturnsEmptyWhenNoMatches () { + // given + when(eventsRepository.findAll()).thenReturn(List.of()); + + // when + var result = eventsService.getEventsForSuperiorGroup("none"); + + // then + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void addEventSavesMappedEntityAndReturnsGeneratedId () { + // given + Date start = new Date(System.currentTimeMillis() + 1_000_000); + Date end = new Date(System.currentTimeMillis() + 2_000_000); + EventDTO dto = new EventDTO() + .setTitle("title") + .setDescription("desc") + .setStartDate(start) + .setEndDate(end) + .setType("Meeting") + .setSuperiorGroups(List.of("12K")); + + // mock event type lookup used by the service + EventType meetingType = new EventType(1, "Meeting"); + when(eventTypeRepository.findByName("Meeting")).thenReturn(java.util.Optional.of(meetingType)); + + // mock save to set id on the passed event instance (service returns event.getId()) + when(eventsRepository.save(any(Event.class))).thenAnswer(invocation -> { + Event e = invocation.getArgument(0); + Field idField = Event.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.setInt(e, 1); + return e; + }); + + // when + int generatedId = eventsService.addEvent(dto); + + // then + assertEquals(1, generatedId); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Event.class); + verify(eventsRepository, times(1)).save(captor.capture()); + Event saved = captor.getValue(); + assertEquals("title", saved.getTitle()); + assertEquals("desc", saved.getDescription()); + assertEquals(start, saved.getStartDate()); + assertEquals(end, saved.getEndDate()); + } + + @Test + void getAllEventTypesReturnsNamesList () { + // given + EventType a = new EventType(1, "Meeting"); + EventType b = new EventType(2, "Exam"); + when(eventTypeRepository.findAll()).thenReturn(List.of(a, b)); + + // when + var types = eventsService.getAllEventTypes(); + + // then + assertEquals(2, types.size()); + assertEquals(List.of("Meeting", "Exam"), types); + } +} \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/examCalendar/ExamControllerTest.java b/src/test/java/org/pkwmtt/calendar/exams/ExamControllerTest.java similarity index 65% rename from src/test/java/org/pkwmtt/examCalendar/ExamControllerTest.java rename to src/test/java/org/pkwmtt/calendar/exams/ExamControllerTest.java index 28de97e..6c3d2a8 100644 --- a/src/test/java/org/pkwmtt/examCalendar/ExamControllerTest.java +++ b/src/test/java/org/pkwmtt/calendar/exams/ExamControllerTest.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar; +package org.pkwmtt.calendar.exams; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -7,16 +7,15 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.pkwmtt.examCalendar.dto.RequestExamDto; -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.calendar.exams.dto.RequestExamDto; +import org.pkwmtt.calendar.exams.entity.Exam; +import org.pkwmtt.calendar.exams.entity.ExamType; +import org.pkwmtt.calendar.exams.entity.StudentGroup; +import org.pkwmtt.calendar.exams.repository.ExamRepository; +import org.pkwmtt.calendar.exams.repository.ExamTypeRepository; +import org.pkwmtt.calendar.exams.repository.GroupRepository; +import org.pkwmtt.security.authentication.authenticationToken.JwtAuthenticationToken; import org.pkwmtt.security.config.NoSecurityConfig; -import org.pkwmtt.security.token.JwtAuthenticationToken; import org.pkwmtt.timetable.TimetableService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.EmbeddedDatabaseConnection; @@ -26,6 +25,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.ResultMatcher; @@ -33,10 +33,7 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -72,11 +69,11 @@ class ExamControllerTest { @Autowired private GroupRepository groupRepository; - @Mock + @MockitoBean private TimetableService timetableService; @BeforeEach - void setupBeforeEach () { + void setupBeforeEach() { examRepository.deleteAll(); examTypeRepository.deleteAll(); groupRepository.deleteAll(); @@ -85,7 +82,7 @@ void setupBeforeEach () { @BeforeEach void setupSecurityContext() { JwtAuthenticationToken auth = new JwtAuthenticationToken( - "user@example.com", + UUID.fromString("11111111-2222-3333-4444-555555555555"), Collections.emptyList(), "12K" ); @@ -104,7 +101,7 @@ void clearSecurityContext() { */ @Test @Transactional - void addExamWithCorrectData () throws Exception { + void addExamWithCorrectData() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestExamDtoRequest = createExampleExamDto("Project"); @@ -114,14 +111,14 @@ void addExamWithCorrectData () throws Exception { 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(); + .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)); @@ -129,14 +126,14 @@ void addExamWithCorrectData () throws Exception { Exam examResponse = examRepository.findById(id).orElseThrow(); Set responseSubgroups = examResponse - .getGroups() - .stream() - .map(StudentGroup::getName) - .collect(Collectors.toSet()); + .getGroups() + .stream() + .map(StudentGroup::getName) + .collect(Collectors.toSet()); Set responseGeneralGroups = responseSubgroups - .stream() - .filter(g -> g.matches("^\\d.*")) - .collect(Collectors.toSet()); + .stream() + .filter(g -> g.matches("^\\d.*")) + .collect(Collectors.toSet()); responseSubgroups.removeAll(responseGeneralGroups); assertEquals(responseGeneralGroups, Set.of("12K")); @@ -146,8 +143,8 @@ void addExamWithCorrectData () throws Exception { assertEquals(requestExamDtoRequest.getDescription(), examResponse.getDescription()); // compare dates with minutes level precision assertEquals( - requestExamDtoRequest.getDate().truncatedTo(ChronoUnit.MINUTES), - examResponse.getExamDate().truncatedTo(ChronoUnit.MINUTES) + requestExamDtoRequest.getDate().truncatedTo(ChronoUnit.MINUTES), + examResponse.getExamDate().truncatedTo(ChronoUnit.MINUTES) ); assertEquals(requestExamDtoRequest.getExamType(), examResponse.getExamType().getName()); @@ -155,7 +152,7 @@ void addExamWithCorrectData () throws Exception { @Test @Transactional - void addExamTwice () throws Exception { + void addExamTwice() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestExamDtoRequest = createExampleExamDto("Project"); @@ -172,17 +169,17 @@ void addExamTwice () throws Exception { } @Test - void addExamWithBlankExamTitle () throws Exception { + void addExamWithBlankExamTitle() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .description("first exam") - .date(LocalDateTime.now().plusDays(1)) - .examType("Project") - .generalGroups(Set.of("12K2")) - .subgroups(Set.of("L04")) - .build(); + .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); @@ -191,17 +188,17 @@ void addExamWithBlankExamTitle () throws Exception { } @Test - void addExamWithBlankExamDescription () throws Exception { + void addExamWithBlankExamDescription() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("Math exam") - .date(LocalDateTime.now().plusDays(1)) - .examType("Project") - .generalGroups(Set.of("12K2")) - .subgroups(Set.of("L04")) - .build(); + .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")); @@ -216,35 +213,35 @@ void addExamWithBlankExamDescription () throws Exception { } @Test - void addExamWithBlankDate () throws Exception { - // given + void addExamWithBlankDate() throws Exception { + //given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("Math exam") - .description("first exam") - .examType("Project") - .generalGroups(Set.of("12K2")) - .subgroups(Set.of("L04")) - .build(); - // when + .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 + //then assertResponseMessage("date : must not be null", result); } @Test - void addExamWithBlankExamGroups () throws Exception { + void addExamWithBlankExamGroups() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("Math exam") - .description("first exam") - .date(LocalDateTime.now().plusDays(1)) - .examType("Project") - .build(); + .builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .build(); // when MvcResult result = assertPostRequest(status().isBadRequest(), requestData); @@ -254,18 +251,18 @@ void addExamWithBlankExamGroups () throws Exception { } @Test - void addExamWithBlankGeneralGroups () throws Exception { + void addExamWithBlankGeneralGroups() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("Math exam") - .description("first exam") - .date(LocalDateTime.now().plusDays(1)) - .examType("Project") - // null generalGroups - .subgroups(Set.of("L04")) - .build(); + .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); @@ -275,18 +272,18 @@ void addExamWithBlankGeneralGroups () throws Exception { @Test @Transactional - void addExamWithBlankSubgroups () throws Exception { + void addExamWithBlankSubgroups() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("Math exam") - .description("first exam") - .date(LocalDateTime.now().plusDays(1)) - .examType("Project") - .generalGroups(Set.of("12K2")) - // null subgroups - .build(); + .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")); @@ -302,18 +299,18 @@ void addExamWithBlankSubgroups () throws Exception { } @Test - void addExamWithMultipleGeneralGroupsAndSubgroups () throws Exception { + void addExamWithMultipleGeneralGroupsAndSubgroups() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("Math exam") - .description("first exam") - .date(LocalDateTime.now().plusDays(1)) - .examType("Project") - .generalGroups(Set.of("12K1", "12K2")) - .subgroups(Set.of("L04")) - .build(); + .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")); @@ -324,18 +321,18 @@ void addExamWithMultipleGeneralGroupsAndSubgroups () throws Exception { } @Test - void addExamWithNullExamTypes () throws Exception { + void addExamWithNullExamTypes() throws Exception { // given RequestExamDto requestData = RequestExamDto - .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(); + .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); @@ -345,18 +342,18 @@ void addExamWithNullExamTypes () throws Exception { } @Test - void addExamWithNotFutureDate () throws Exception { + void addExamWithNotFutureDate() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("Math exam") - .description("first exam") - .date(LocalDateTime.now().minusDays(1)) - .examType("Project") - .generalGroups(Set.of("12K2")) - .subgroups(Set.of("L04")) - .build(); + .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); @@ -365,18 +362,18 @@ void addExamWithNotFutureDate () throws Exception { } @Test - void addExamWithEmptyStringExamTitle () throws Exception { + void addExamWithEmptyStringExamTitle() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("") - .description("first exam") - .date(LocalDateTime.now().plusDays(1)) - .examType("Project") - .generalGroups(Set.of("12K2")) - .subgroups(Set.of("L04")) - .build(); + .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); @@ -386,18 +383,18 @@ void addExamWithEmptyStringExamTitle () throws Exception { } @Test - void addExamWithTooLongExamTitle () throws Exception { + void addExamWithTooLongExamTitle() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("a".repeat(256)) - .description("first exam") - .date(LocalDateTime.now().plusDays(1)) - .examType("Project") - .generalGroups(Set.of("12K2")) - .subgroups(Set.of("L04")) - .build(); + .builder() + .title("a".repeat(256)) + .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); @@ -407,18 +404,18 @@ void addExamWithTooLongExamTitle () throws Exception { } @Test - void addExamWithTooLongDescription () throws Exception { + void addExamWithTooLongDescription() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("Math exam") - .description("a".repeat(256)) - .date(LocalDateTime.now().plusDays(1)) - .examType("Project") - .generalGroups(Set.of("12K2")) - .subgroups(Set.of("L04")) - .build(); + .builder() + .title("Math exam") + .description("a".repeat(256)) + .date(LocalDateTime.now().plusDays(1)) + .examType("Project") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); // when MvcResult result = assertPostRequest(status().isBadRequest(), requestData); @@ -428,18 +425,18 @@ void addExamWithTooLongDescription () throws Exception { } @Test - void addExamWithNonExistingExamType () throws Exception { + void addExamWithNonExistingExamType() throws Exception { // given createExampleExamType("Project"); RequestExamDto requestData = RequestExamDto - .builder() - .title("Math exam") - .description("first exam") - .date(LocalDateTime.now().plusDays(1)) - .examType("NonExistingExamType") - .generalGroups(Set.of("12K2")) - .subgroups(Set.of("L04")) - .build(); + .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")); @@ -457,7 +454,7 @@ void addExamWithNonExistingExamType () throws Exception { // @Test @Transactional - void modifyExamWithCorrectData () throws Exception { + void modifyExamWithCorrectData() throws Exception { // given LocalDateTime date = LocalDateTime.now().plusDays(1); ExamType examType = createExampleExamType("Exam"); @@ -475,14 +472,14 @@ void modifyExamWithCorrectData () throws Exception { Exam responseExam = examRepository.findById(id).orElseThrow(); Set responseSubgroups = responseExam - .getGroups() - .stream() - .map(StudentGroup::getName) - .collect(Collectors.toSet()); + .getGroups() + .stream() + .map(StudentGroup::getName) + .collect(Collectors.toSet()); Set responseGeneralGroups = responseSubgroups - .stream() - .filter(g -> g.matches("^\\d.*")) - .collect(Collectors.toSet()); + .stream() + .filter(g -> g.matches("^\\d.*")) + .collect(Collectors.toSet()); responseSubgroups.removeAll(responseGeneralGroups); assertEquals("Math exam", responseExam.getTitle()); @@ -493,7 +490,7 @@ void modifyExamWithCorrectData () throws Exception { } @Test - void modifyExamWithIncorrectExamId () throws Exception { + void modifyExamWithIncorrectExamId() throws Exception { // given ExamType examType = createExampleExamType("Exam"); Exam exam = createExampleExam(examType); @@ -513,7 +510,7 @@ void modifyExamWithIncorrectExamId () throws Exception { // @Test - void deleteExamWithCorrectArguments () throws Exception { + void deleteExamWithCorrectArguments() throws Exception { // given ExamType examType = createExampleExamType("Exam"); Exam exam = createExampleExam(examType); @@ -527,7 +524,8 @@ void deleteExamWithCorrectArguments () throws Exception { } @Test - void deleteNonExistingExam () throws Exception { + @Disabled("move to controller") + void deleteNonExistingExam() throws Exception { // given ExamType examType = createExampleExamType("Exam"); Exam exam = createExampleExam(examType); @@ -549,7 +547,7 @@ void deleteNonExistingExam () throws Exception { @Test @Disabled("Endpoint are disabled") - void getExamByIdWithCorrectId () throws Exception { + void getExamByIdWithCorrectId() throws Exception { // given ExamType examType = createExampleExamType("Exam"); Exam exam = createExampleExam(examType); @@ -563,19 +561,19 @@ void getExamByIdWithCorrectId () throws Exception { 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) + 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") + mapper.readTree(mapper.writeValueAsString(exam.getExamType())), + responseNode.get("examType") ); } @Test @Disabled("Endpoint are disabled") - void getNonExistingExamById () throws Exception { + void getNonExistingExamById() throws Exception { // given ExamType examType = createExampleExamType("Exam"); Exam exam = createExampleExam(examType); @@ -593,7 +591,7 @@ void getNonExistingExamById () throws Exception { // @Test - void getExamsWithGeneralGroups () throws Exception { + void getExamsWithGeneralGroups() throws Exception { // given Exam exam1 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex1", Set.of("12K2"))); Exam exam2 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex2", Set.of("12K2", "12K1"))); @@ -619,7 +617,7 @@ void getExamsWithGeneralGroups () throws Exception { } @Test - void getExamsWithSubgroups () throws Exception { + void getExamsWithSubgroups() throws Exception { // given Exam exam1 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex1", Set.of("12K2"))); Exam exam2 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex2", Set.of("12K2", "11K2"))); @@ -641,7 +639,7 @@ void getExamsWithSubgroups () throws Exception { } @Test - void getExamsWithSubgroupsUsingWholeYearIdentifier () throws Exception { + void getExamsWithSubgroupsUsingWholeYearIdentifier() throws Exception { // given Exam exam1 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex1", Set.of("12K2"))); Exam exam2 = examRepository.save(createAndSaveExamWithTitleAndGroups("ex2", Set.of("12K2", "11K2"))); @@ -665,36 +663,36 @@ void getExamsWithSubgroupsUsingWholeYearIdentifier () throws Exception { } @Test - void getExamsMultipleGeneralGroupsAndSubgroups () throws Exception { + void getExamsMultipleGeneralGroupsAndSubgroups() throws Exception { // when MvcResult result = assertGetByGroupsRequest( - status().isBadRequest(), - Set.of("11K2", "12A1"), - Set.of("L04") + status().isBadRequest(), + Set.of("11K2", "12A1"), + Set.of("L04") ); // then assertResponseMessage("Invalid group identifier: ambiguous general groups for subgroups", result); } @Test - void getExamsWithSwappedGroupNames () throws Exception { + void getExamsWithSwappedGroupNames() throws Exception { // when MvcResult result = assertGetByGroupsRequest( - status().isBadRequest(), - Set.of("K04"), - Set.of("11K2", "12A1") + status().isBadRequest(), + Set.of("K04"), + Set.of("11K2", "12A1") ); // then assertResponseMessage("Specified general group [K04] doesn't exists", result); } @Test - void getExamsWithInvalidSubgroup () throws Exception { + void getExamsWithInvalidSubgroup() throws Exception { // when MvcResult result = assertGetByGroupsRequest( - status().isBadRequest(), - Set.of("12K1", "12K2"), - Set.of("11K2") + status().isBadRequest(), + Set.of("12K1", "12K2"), + Set.of("11K2") ); // then assertResponseMessage("Specified sub group [11K2] doesn't exists", result); @@ -703,7 +701,7 @@ void getExamsWithInvalidSubgroup () throws Exception { // @Test - void getExamTypesWhenExamTypesExists () throws Exception { + void getExamTypesWhenExamTypesExists() throws Exception { // given ExamType exam = createExampleExamType("Exam"); ExamType project = createExampleExamType("Project"); @@ -719,14 +717,14 @@ void getExamTypesWhenExamTypesExists () throws Exception { } @Test - void getExamTypesWhenExamTypesNotExists () throws Exception { + 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(); + .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 @@ -743,7 +741,7 @@ void getExamTypesWhenExamTypesNotExists () throws Exception { * @param name of new examType * @return created examType object */ - private ExamType createExampleExamType (String name) { + private ExamType createExampleExamType(String name) { ExamType examType = ExamType.builder().name(name).build(); examTypeRepository.save(examType); return examType; @@ -751,81 +749,82 @@ private ExamType createExampleExamType (String name) { /** * 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) { + private Exam createExampleExam(ExamType type) { List savedGroups = groupRepository.saveAll(Stream - .of("12K2", "L04") - .map(g -> StudentGroup - .builder() - .name(g) - .build()) - .collect(Collectors.toList())); + .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(); + .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) { + private Exam createAndSaveExamWithTitleAndGroups(String title, Set groups) { ExamType examType = examTypeRepository - .findByName("Project") - .orElseGet(() -> createExampleExamType("Project")); + .findByName("Project") + .orElseGet(() -> createExampleExamType("Project")); Set groupsFromRepository = groupRepository - .findAll() - .stream() - .map(StudentGroup::getName) - .collect(Collectors.toSet()); + .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())); + .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()); + .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(); + .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 RequestExamDto */ - private RequestExamDto createExampleExamDto (String examTypeName) { + private RequestExamDto createExampleExamDto(String examTypeName) { return RequestExamDto - .builder() - .title("Math exam") - .description("first exam") - .date(LocalDateTime.now().plusDays(1)) - .examType(examTypeName) - .generalGroups(Set.of("12K2")) - .subgroups(Set.of("L04")) - .build(); + .builder() + .title("Math exam") + .description("first exam") + .date(LocalDateTime.now().plusDays(1)) + .examType(examTypeName) + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); } /** * @param examTypeName name of type of exam as String - * @param date . + * @param date . * @return created RequestExamDto */ - private RequestExamDto createExampleExamDto (String examTypeName, LocalDateTime date) { + private RequestExamDto createExampleExamDto(String examTypeName, LocalDateTime date) { return RequestExamDto .builder() .title("Math exam") @@ -843,7 +842,7 @@ private RequestExamDto createExampleExamDto (String examTypeName, LocalDateTime * @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 { + 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()); @@ -858,15 +857,15 @@ private void assertResponseMessage (String expectedMessage, MvcResult result) th * 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 { + 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(); + .perform(MockMvcRequestBuilders + .post("/pkwmtt/api/v1/exams") + .contentType("application/json") + .content(mapper.writeValueAsString(content))) + .andDo(print()) + .andExpect(expectedStatus) + .andReturn(); } /** @@ -878,16 +877,16 @@ private MvcResult assertPostRequest (ResultMatcher expectedStatus, Object conten * @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 { + 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(); + .perform(MockMvcRequestBuilders + .put("/pkwmtt/api/v1/exams/{id}", pathId) + .contentType("application/json") + .content(mapper.writeValueAsString(content))) + .andDo(print()) + .andExpect(expectedStatus) + .andReturn(); } /** @@ -898,14 +897,14 @@ private MvcResult assertPutRequest (ResultMatcher expectedStatus, Object content * @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 { + 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(); + .perform(MockMvcRequestBuilders + .delete("/pkwmtt/api/v1/exams/{id}", pathId) + .contentType("application/json")) + .andDo(print()) + .andExpect(expectedStatus) + .andReturn(); } /** @@ -916,39 +915,39 @@ private MvcResult assertDeleteRequest (ResultMatcher expectedStatus, int pathId) * @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 { + 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(); + .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 { + 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(); + .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 { + 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(); + .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(); } /** @@ -958,12 +957,12 @@ private MvcResult assertGetByGroupsRequest (ResultMatcher expectedStatus, Set diff --git a/src/test/java/org/pkwmtt/examCalendar/ExamServiceTest.java b/src/test/java/org/pkwmtt/calendar/exams/ExamServiceTest.java similarity index 82% rename from src/test/java/org/pkwmtt/examCalendar/ExamServiceTest.java rename to src/test/java/org/pkwmtt/calendar/exams/ExamServiceTest.java index d18a865..71f463a 100644 --- a/src/test/java/org/pkwmtt/examCalendar/ExamServiceTest.java +++ b/src/test/java/org/pkwmtt/calendar/exams/ExamServiceTest.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar; +package org.pkwmtt.calendar.exams; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.JsonProcessingException; @@ -10,16 +10,17 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.pkwmtt.examCalendar.dto.RequestExamDto; -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.calendar.exams.dto.RequestExamDto; +import org.pkwmtt.calendar.exams.entity.Exam; +import org.pkwmtt.calendar.exams.entity.ExamType; +import org.pkwmtt.calendar.exams.entity.StudentGroup; +import org.pkwmtt.calendar.exams.mapper.ExamDtoMapper; +import org.pkwmtt.calendar.exams.repository.ExamRepository; +import org.pkwmtt.calendar.exams.repository.ExamTypeRepository; +import org.pkwmtt.calendar.exams.repository.GroupRepository; +import org.pkwmtt.calendar.exams.services.ExamService; import org.pkwmtt.exceptions.*; -import org.pkwmtt.security.token.JwtAuthenticationToken; +import org.pkwmtt.security.authentication.authenticationToken.JwtAuthenticationToken; import org.pkwmtt.timetable.TimetableService; import org.springframework.security.core.context.SecurityContextHolder; @@ -51,14 +52,13 @@ class ExamServiceTest { private ExamService examService; @BeforeEach - void setupSecurityContextHolder(){ - JwtAuthenticationToken token = new JwtAuthenticationToken( - "user@example.com", + void setupSecurityContext() { + JwtAuthenticationToken auth = new JwtAuthenticationToken( + UUID.fromString("11111111-2222-3333-4444-555555555555"), Collections.emptyList(), "12K" ); - - SecurityContextHolder.getContext().setAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); } // @@ -72,8 +72,8 @@ void setupSecurityContextHolder(){ * groupRepository - don't contain provided groups */ @Test - void testBlankSubgroupAndMoreArgumentsThatRequiredReturnedByService() { -// given + void testBlankSubgroupAndMoreArgumentsThatRequiredReturnedByService() throws JsonProcessingException { + // given Set g12K2 = Set.of("12K2"); LocalDateTime date = LocalDateTime.now().plusDays(1); @@ -86,17 +86,17 @@ void testBlankSubgroupAndMoreArgumentsThatRequiredReturnedByService() { .build(); ExamType examType = buildExampleExamType(); List studentGroups = buildExampleStudentGroupList(g12K2); - Exam exam = buildExamWithIdAndGroups(1, studentGroups); + Exam exam = buildExamWithIdAndGroups(studentGroups); when(examTypeRepository.findByName(requestExamDto.getExamType())).thenReturn(Optional.of(examType)); -// more groups than in set + // 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 + // when int savedId = examService.addExam(requestExamDto); -// then + // then verify(examTypeRepository, times(1)).findByName(requestExamDto.getExamType()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(1)).findAllByNameIn(g12K2); @@ -121,7 +121,7 @@ void testBlankSubgroupAndMoreArgumentsThatRequiredReturnedByService() { * groupRepository - don't contain provided groups */ @Test - void addExamForMultipleGeneralGroupsWithEmptySubgroups() { + void addExamForMultipleGeneralGroupsWithEmptySubgroups() throws JsonProcessingException { // given Set generalGroups = Set.of("12K1", "12K2", "12K3"); Set subgroups = Set.of(); @@ -130,7 +130,7 @@ void addExamForMultipleGeneralGroupsWithEmptySubgroups() { RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); ExamType examType = buildExampleExamType(); List studentGroups = buildExampleStudentGroupList(generalGroups); - Exam exam = buildExamWithIdAndGroups(1, studentGroups); + Exam exam = buildExamWithIdAndGroups(studentGroups); when(examTypeRepository.findByName(requestExamDto.getExamType())).thenReturn(Optional.of(examType)); when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(generalGroups)); @@ -138,9 +138,9 @@ void addExamForMultipleGeneralGroupsWithEmptySubgroups() { when(groupRepository.findAllByNameIn(generalGroups)).thenReturn(new HashSet<>(Set.of())); when(groupRepository.saveAll(anyList())).thenReturn(studentGroups); when(examRepository.save(any(Exam.class))).thenReturn(exam); -// when + // when int savedId = examService.addExam(requestExamDto); -// then + // then verify(examTypeRepository, times(1)).findByName(requestExamDto.getExamType()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(1)).findAllByNameIn(generalGroups); @@ -148,7 +148,11 @@ void addExamForMultipleGeneralGroupsWithEmptySubgroups() { @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()); + Set capturedGroups = groupCaptor + .getValue() + .stream() + .map(StudentGroup::getName) + .collect(Collectors.toSet()); assertEquals(generalGroups, capturedGroups); ArgumentCaptor examCaptor = ArgumentCaptor.forClass(Exam.class); @@ -171,7 +175,8 @@ void addExamForSingleGeneralGroupAndSingleSubgroup() throws JsonProcessingExcept // 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"))); + when(timetableService.getAvailableSubGroups(any(String.class))).thenReturn( + new ArrayList<>(List.of("K03", "K04", "L04"))); testExamServiceForSubgroups(generalGroups, subgroups); } @@ -188,7 +193,8 @@ void addExamForSingleGeneralGroupAndMultipleSubgroup() throws JsonProcessingExce // 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"))); + when(timetableService.getAvailableSubGroups(any(String.class))).thenReturn( + new ArrayList<>(List.of("K03", "K04", "P04", "L04", "L03"))); testExamServiceForSubgroups(generalGroups, subgroups); } @@ -228,16 +234,16 @@ void addExamThatAlreadyExists() throws JsonProcessingException { when(timetableService.getAvailableSubGroups("12K2")).thenReturn(new ArrayList<>(List.of("L04"))); //noinspection unchecked when(groupRepository.findAllByNameIn(any(Set.class))).thenReturn(studentGroups); -// + // when(examTypeRepository.findByName(requestExamDto.getExamType())).thenReturn(Optional.of(examType)); when(examRepository.findAllByTitle(requestExamDto.getTitle())).thenReturn(Set.of(exam)); -// when + // when RuntimeException exception = assertThrows( ResourceAlreadyExistsException.class, () -> examService.addExam(requestExamDto) ); -// then + // then verify(examRepository, times(0)).save(exam); assertEquals("Exam already exists", exception.getMessage()); @@ -256,7 +262,7 @@ void addExamThatAlreadyExists() throws JsonProcessingException { * groupRepository - don't contain provided groups */ @Test - void shouldThrowWhenGeneralGroupsDontMatchService() { + void shouldThrowWhenGeneralGroupsDontMatchService() throws JsonProcessingException { // given Set generalGroups = Set.of("12K1", "12K2"); Set subgroups = Set.of(); @@ -264,14 +270,15 @@ void shouldThrowWhenGeneralGroupsDontMatchService() { LocalDateTime date = LocalDateTime.now().plusDays(1); RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(List.of())); -// when - RuntimeException exception = assertThrows(InvalidGroupIdentifierException.class, () -> examService.addExam(requestExamDto)); -// then + // when + RuntimeException exception = assertThrows( + InvalidGroupIdentifierException.class, () -> examService.addExam(requestExamDto)); + // then assertEquals("Invalid group identifiers: [12K1, 12K2]", exception.getMessage()); } @Test - void shouldThrowWhenNotAllGeneralGroupsMatchService() { + void shouldThrowWhenNotAllGeneralGroupsMatchService() throws JsonProcessingException { // given Set generalGroups = Set.of("12K1", "12K2"); Set subgroups = Set.of(); @@ -279,9 +286,10 @@ void shouldThrowWhenNotAllGeneralGroupsMatchService() { LocalDateTime date = LocalDateTime.now().plusDays(1); RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(List.of("12K1"))); -// when - RuntimeException exception = assertThrows(InvalidGroupIdentifierException.class, () -> examService.addExam(requestExamDto)); -// then + // when + RuntimeException exception = assertThrows( + InvalidGroupIdentifierException.class, () -> examService.addExam(requestExamDto)); + // then assertEquals("Invalid group identifiers: [12K2]", exception.getMessage()); } @@ -303,9 +311,10 @@ void shouldThrowWhenSubgroupsDontMatchService() throws JsonProcessingException { RequestExamDto requestExamDto = 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(requestExamDto)); -// then + // when + RuntimeException exception = assertThrows( + InvalidGroupIdentifierException.class, () -> examService.addExam(requestExamDto)); + // then String message = exception.getMessage(); assertTrue(message.startsWith("Invalid group identifiers:")); assertFalse(message.contains("12K2")); @@ -325,9 +334,10 @@ void shouldThrowWhenNotAllSubgroupsMatchService() throws JsonProcessingException RequestExamDto requestExamDto = 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(requestExamDto)); -// then + // when + RuntimeException exception = assertThrows( + InvalidGroupIdentifierException.class, () -> examService.addExam(requestExamDto)); + // then String message = exception.getMessage(); assertTrue(message.startsWith("Invalid group identifiers:")); assertFalse(message.contains("12K2")); @@ -350,7 +360,7 @@ void shouldThrowWhenNotAllSubgroupsMatchService() throws JsonProcessingException * groupRepository - contain provided groups */ @Test - void addExamForSingleGeneralGroupWithRepositoryContainingGroup() { + void addExamForSingleGeneralGroupWithRepositoryContainingGroup() throws JsonProcessingException { // given Set generalGroups = Set.of("12K2"); Set subgroups = Set.of(); @@ -359,7 +369,7 @@ void addExamForSingleGeneralGroupWithRepositoryContainingGroup() { RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); ExamType examType = buildExampleExamType(); List studentGroups = buildExampleStudentGroupList(generalGroups); - Exam exam = buildExamWithIdAndGroups(1, studentGroups); + Exam exam = buildExamWithIdAndGroups(studentGroups); when(examTypeRepository.findByName(requestExamDto.getExamType())).thenReturn(Optional.of(examType)); when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(generalGroups)); @@ -367,9 +377,9 @@ void addExamForSingleGeneralGroupWithRepositoryContainingGroup() { when(groupRepository.findAllByNameIn(generalGroups)).thenReturn(new HashSet<>(studentGroups)); when(groupRepository.saveAll(any())).thenReturn(List.of()); when(examRepository.save(any(Exam.class))).thenReturn(exam); -// when + // when int savedId = examService.addExam(requestExamDto); -// then + // then verify(examTypeRepository, times(1)).findByName(requestExamDto.getExamType()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(1)).findAllByNameIn(any()); //??? @@ -382,7 +392,7 @@ void addExamForSingleGeneralGroupWithRepositoryContainingGroup() { } @Test - void addExamWithNonUniqueTitle() { + void addExamWithNonUniqueTitle() throws JsonProcessingException { // given Set generalGroups = Set.of("12K2"); Set subgroups = Set.of(); @@ -391,7 +401,7 @@ void addExamWithNonUniqueTitle() { RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); ExamType examType = buildExampleExamType(); List studentGroups = buildExampleStudentGroupList(generalGroups); - Exam newExam = buildExamWithIdAndGroups(1, studentGroups); + Exam newExam = buildExamWithIdAndGroups(studentGroups); Exam existingExam = Exam.builder() .title("title") .description("description") @@ -407,9 +417,9 @@ void addExamWithNonUniqueTitle() { when(groupRepository.saveAll(any())).thenReturn(List.of()); when(examRepository.findAllByTitle(any())).thenReturn(Set.of(existingExam)); when(examRepository.save(any(Exam.class))).thenReturn(newExam); -// when + // when int savedId = examService.addExam(requestExamDto); -// then + // then verify(examTypeRepository, times(1)).findByName(requestExamDto.getExamType()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(1)).findAllByNameIn(any()); //??? @@ -430,7 +440,8 @@ void addExamWithNonUniqueTitle() { * groupRepository - partially contain provided groups */ @Test - void addExamForSingleGeneralGroupAndSubgroupsWithRepositoryContainingGroups() throws JsonProcessingException { + void addExamForSingleGeneralGroupAndSubgroupsWithRepositoryContainingGroups() + throws JsonProcessingException { // given Set generalGroups = Set.of("12K2"); Set subgroups = Set.of("K04", "P04", "L04", "K05"); @@ -440,19 +451,20 @@ void addExamForSingleGeneralGroupAndSubgroupsWithRepositoryContainingGroups() th RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); ExamType examType = buildExampleExamType(); List studentGroups = buildExampleStudentGroupList(combinedGroups); - Exam exam = buildExamWithIdAndGroups(1, studentGroups); + Exam exam = buildExamWithIdAndGroups(studentGroups); when(examTypeRepository.findByName(requestExamDto.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.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 + // when int savedId = examService.addExam(requestExamDto); -// then + // then verify(examTypeRepository, times(1)).findByName(requestExamDto.getExamType()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(1)).findAllByNameIn(any()); @@ -477,21 +489,23 @@ void addExamForSingleGeneralGroupAndSubgroupsWithRepositoryContainingGroups() th * groupRepository - don't contain provided groups */ @Test - void unavailableServiceAndRepositoryDontMatch() { -// given + void unavailableServiceAndRepositoryDontMatch() throws JsonProcessingException { + // given Set generalGroups = Set.of("12K2"); Set subgroups = Set.of(); LocalDateTime date = LocalDateTime.now().plusDays(1); RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); -// more groups than in set + // 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(requestExamDto)); -// then - assertEquals("Timetable service unavailable, couldn't verify groups using repository", exception.getMessage()); + // when + RuntimeException exception = assertThrows( + ServiceNotAvailableException.class, () -> examService.addExam(requestExamDto)); + // then + assertEquals( + "Timetable service unavailable, couldn't verify groups using repository", exception.getMessage()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(1)).findAllByNameIn(generalGroups); } @@ -507,7 +521,7 @@ void unavailableServiceAndRepositoryDontMatch() { */ @Test void unavailableServiceAndRepositoryDontMatchForSubgroups() throws JsonProcessingException { -// given + // given Set generalGroups = Set.of("12K2"); Set subgroups = Set.of("L04", "K04", "P04"); @@ -515,14 +529,16 @@ void unavailableServiceAndRepositoryDontMatchForSubgroups() throws JsonProcessin RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); List studentGroups = buildExampleStudentGroupList(Set.of("12K2", "L04")); -// more groups than in set + // 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(requestExamDto)); -// then - assertEquals("Timetable service unavailable, couldn't verify groups using repository", exception.getMessage()); + // when + RuntimeException exception = assertThrows( + ServiceNotAvailableException.class, () -> examService.addExam(requestExamDto)); + // then + assertEquals( + "Timetable service unavailable, couldn't verify groups using repository", exception.getMessage()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(1)).findAllByNameIn(generalGroups); } @@ -540,7 +556,7 @@ void unavailableServiceAndRepositoryDontMatchForSubgroups() throws JsonProcessin * groupRepository - contain provided groups */ @Test - void addExamWhenServiceIsUnavailableAndRepositoryContainsGeneralGroups() { + void addExamWhenServiceIsUnavailableAndRepositoryContainsGeneralGroups() throws JsonProcessingException { // given Set generalGroups = Set.of("12K1", "12K2"); Set subgroups = Set.of(); @@ -549,7 +565,7 @@ void addExamWhenServiceIsUnavailableAndRepositoryContainsGeneralGroups() { RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); ExamType examType = buildExampleExamType(); List studentGroups = buildExampleStudentGroupList(generalGroups); - Exam exam = buildExamWithIdAndGroups(1, studentGroups); + Exam exam = buildExamWithIdAndGroups(studentGroups); when(examTypeRepository.findByName(requestExamDto.getExamType())).thenReturn(Optional.of(examType)); when(timetableService.getGeneralGroupList()).thenThrow(new WebPageContentNotAvailableException()); @@ -557,9 +573,9 @@ void addExamWhenServiceIsUnavailableAndRepositoryContainsGeneralGroups() { when(groupRepository.findAllByNameIn(generalGroups)).thenReturn(new HashSet<>(studentGroups)); when(groupRepository.saveAll(anyList())).thenReturn(studentGroups); when(examRepository.save(any(Exam.class))).thenReturn(exam); -// when + // when int savedId = examService.addExam(requestExamDto); -// then + // then verify(examTypeRepository, times(1)).findByName(requestExamDto.getExamType()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(2)).findAllByNameIn(any()); @@ -590,11 +606,12 @@ void addExamWhenServiceIsUnavailableAndRepositoryContainsGroups() throws JsonPro RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); ExamType examType = buildExampleExamType(); List studentGroups = buildExampleStudentGroupList(combinedGroups); - Exam exam = buildExamWithIdAndGroups(1, studentGroups); + Exam exam = buildExamWithIdAndGroups(studentGroups); when(examTypeRepository.findByName(requestExamDto.getExamType())).thenReturn(Optional.of(examType)); when(timetableService.getGeneralGroupList()).thenThrow(new WebPageContentNotAvailableException()); - when(timetableService.getAvailableSubGroups("12K2")).thenThrow(new JsonParseException("parsing subgroups failed")); + when(timetableService.getAvailableSubGroups("12K2")).thenThrow( + new JsonParseException("parsing subgroups failed")); //noinspection unchecked when(groupRepository.findAllByNameIn(any(Set.class))).thenReturn(new HashSet<>(studentGroups)); @@ -603,9 +620,9 @@ void addExamWhenServiceIsUnavailableAndRepositoryContainsGroups() throws JsonPro when(examRepository.save(any(Exam.class))).thenReturn(exam); //noinspection unchecked when(examRepository.findCommonExamIdsForGroups(any(Set.class), any(Integer.class))).thenReturn(Set.of(1)); -// when + // when int savedId = examService.addExam(requestExamDto); -// then + // then verify(examTypeRepository, times(1)).findByName(requestExamDto.getExamType()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(2)).findAllByNameIn(any()); @@ -619,62 +636,50 @@ void addExamWhenServiceIsUnavailableAndRepositoryContainsGroups() throws JsonPro // -//modify exam + //modify exam - /************************************************************************************/ -//delete exam - @Test - void shouldDeleteExamWhenIdExists() { -// given - int examId = 1; - when(examRepository.findById(examId)).thenReturn(Optional.of(mock(Exam.class))); - when(examRepository.findGroupsByExamId(examId)).thenReturn(Set.of("12K2")); -// when - examService.deleteExam(examId); -// then - verify(examRepository).deleteById(examId); - } @Test + @Disabled("move test to controller") void shouldThrowExceptionWhenExamIdNotExists() { -// given + // given int examId = 5; when(examRepository.findById(examId)).thenThrow(new NoSuchElementException("Exam not found")); -// when + // when RuntimeException exception = assertThrows( NoSuchElementException.class, () -> examService.deleteExam(examId) ); -// then + // then verify(examRepository, never()).deleteById(examId); assertEquals("Exam not found", exception.getMessage()); } /************************************************************************************/ -// getExamById + // getExamById @Test void getExamById() { -// given + // given int examId = 1; when(examRepository.findById(examId)).thenReturn(Optional.of(mock(Exam.class))); -// when + // when Exam exam = examService.getExamById(examId); -// then + // then verify(examRepository).findById(examId); assertNotNull(exam); } @Test void shouldThrowExceptionWhenExamNotFound() { -// given + // given int examId = 5; when(examRepository.findById(examId)).thenThrow(new NoSuchElementException("Exam not found")); -// when + // when RuntimeException exception = assertThrows( NoSuchElementException.class, () -> examService.getExamById(examId) ); -// then + // then assertEquals("Exam not found", exception.getMessage()); } @@ -682,70 +687,73 @@ void shouldThrowExceptionWhenExamNotFound() { @Test void getExamsForNormalGroups() { -// given + // given Set generalGroups = Set.of("12K2"); Set subgroups = Set.of("L04", "K04", "P04"); -// when + // when examService.getExamByGroups(generalGroups, subgroups); -// then - verify(examRepository, never()).findAllByGroups_NameIn(any()); - verify(examRepository, times(1)).findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K", Set.of("12K2"), subgroups); + // then + verify(examRepository, never()).findAllByGroups_NameIn(any()); + verify(examRepository, times(1)).findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K", Set.of("12K2"), subgroups); } @Test void getExamsForGroupWithoutDigitAsLastCharacter() { -// given + // given Set generalGroups = Set.of("1Er"); Set subgroups = Set.of("L01", "K01", "P01"); -// when + // when examService.getExamByGroups(generalGroups, subgroups); -// then + // then verify(examRepository, never()).findAllByGroups_NameIn(any()); - verify(examRepository, times(1)).findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("1Er", generalGroups, subgroups); + verify(examRepository, times(1)).findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "1Er", generalGroups, subgroups); } @Test void getExamsWithEmptySubgroups() { -// given + // given Set generalGroups = Set.of("12K2"); Set subgroups = Set.of(); -// when + // when examService.getExamByGroups(generalGroups, subgroups); -// then + // then verify(examRepository, times(1)).findAllByGroups_NameIn(generalGroups); verify(examRepository, never()).findAllBySubgroupsOfSuperiorGroupAndGeneralGroup(any(), any(), any()); } @Test void getExamsWithBlankSubgroups() { -// given + // given Set generalGroups = Set.of("12K2"); Set subgroups = null; -// when + // when examService.getExamByGroups(generalGroups, subgroups); -// then + // then verify(examRepository, times(1)).findAllByGroups_NameIn(generalGroups); verify(examRepository, never()).findAllBySubgroupsOfSuperiorGroupAndGeneralGroup(any(), any(), any()); } @Test void shouldNotThrowWhenGroupsAreFromTheSameYearOfStudy() { -// given + // given Set generalGroups = Set.of("12K1", "12K2"); Set subgroups = Set.of("L01", "K01", "P01"); -// when + // when examService.getExamByGroups(generalGroups, subgroups); -// then + // then verify(examRepository, never()).findAllByGroups_NameIn(any()); - verify(examRepository, times(1)).findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K", generalGroups, subgroups); + verify(examRepository, times(1)).findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K", generalGroups, subgroups); } @Test void shouldThrowWhenSubgroupsAreSwappedWithGeneralGroups() { -// given + // given Set generalGroups = new HashSet<>(Set.of("L01", "K01", "P01")); - Set subgroups = new HashSet<>( Set.of("12K1")); -// when then + Set subgroups = new HashSet<>(Set.of("12K1")); + // when then assertThrows( InvalidGroupIdentifierException.class, () -> examService.getExamByGroups(generalGroups, subgroups) @@ -754,10 +762,10 @@ void shouldThrowWhenSubgroupsAreSwappedWithGeneralGroups() { @Test void shouldThrowWhenSubgroupsAreTheGeneralGroups() { -// given + // given Set generalGroups = new HashSet<>(Set.of("12K1")); - Set subgroups = new HashSet<>( Set.of("12K1", "12K2", "12K3")); -// when, then + Set subgroups = new HashSet<>(Set.of("12K1", "12K2", "12K3")); + // when, then assertThrows( SpecifiedSubGroupDoesntExistsException.class, () -> examService.getExamByGroups(generalGroups, subgroups) @@ -766,30 +774,35 @@ void shouldThrowWhenSubgroupsAreTheGeneralGroups() { @Test void shouldThrowWhenGeneralGroupsAreFromDifferentYearOfStudy() { -// given + // 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 + // when + RuntimeException exception = assertThrows( + InvalidGroupIdentifierException.class, () -> examService.getExamByGroups(generalGroups, subgroups)); + // then assertEquals("Invalid group identifier: ambiguous general groups for subgroups", exception.getMessage()); } - + // Updated helper methods to match new schema private static List buildExampleStudentGroupList(Set groupNames) { - AtomicInteger id = new AtomicInteger(); + AtomicInteger id = new AtomicInteger(1); // group_id starts from 1 return groupNames.stream() .map(g -> StudentGroup.builder() .groupId(id.getAndIncrement()) .name(g) - .build() - ).collect(Collectors.toList()); + .build()) + .collect(Collectors.toList()); } - private static Exam buildExamWithIdAndGroups(int id, List groups) { + private static Exam buildExamWithIdAndGroups(List groups) { return Exam.builder() - .examId(id) + .examId(1) + .title("title") + .description("description") + .examDate(LocalDateTime.now().plusDays(1)) + .examType(buildExampleExamType()) .groups(new HashSet<>(groups)) .build(); } @@ -801,7 +814,9 @@ private static ExamType buildExampleExamType() { .build(); } - private static RequestExamDto buildExampleExamDto(Set generalGroups, Set subgroups, LocalDateTime date) { + private static RequestExamDto buildExampleExamDto(Set generalGroups, + Set subgroups, + LocalDateTime date) { return RequestExamDto.builder() .title("title") .description("description") @@ -818,11 +833,13 @@ private static void assertExam(Exam savedExam, LocalDateTime date, int savedId, 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( + groups, savedExam.getGroups().stream().map(StudentGroup::getName).collect(Collectors.toSet())); assertEquals(1, savedId); } - private void testExamServiceForSubgroups(Set generalGroups, Set subgroups) { + private void testExamServiceForSubgroups(Set generalGroups, Set subgroups) + throws JsonProcessingException { Set combinedGroups = new HashSet<>(subgroups); combinedGroups.addAll(generalGroups.stream() .map(g -> g.matches(".*\\d$") ? g.substring(0, g.length() - 1) : g) @@ -832,7 +849,7 @@ private void testExamServiceForSubgroups(Set generalGroups, Set RequestExamDto requestExamDto = buildExampleExamDto(generalGroups, subgroups, date); ExamType examType = buildExampleExamType(); List studentGroups = buildExampleStudentGroupList(combinedGroups); - Exam exam = buildExamWithIdAndGroups(1, studentGroups); + Exam exam = buildExamWithIdAndGroups(studentGroups); when(examTypeRepository.findByName(requestExamDto.getExamType())).thenReturn(Optional.of(examType)); when(timetableService.getGeneralGroupList()).thenReturn(new ArrayList<>(generalGroups)); @@ -840,9 +857,9 @@ private void testExamServiceForSubgroups(Set generalGroups, Set when(groupRepository.findAllByNameIn(combinedGroups)).thenReturn(new HashSet<>(Set.of())); when(groupRepository.saveAll(anyList())).thenReturn(studentGroups); when(examRepository.save(any(Exam.class))).thenReturn(exam); -// when + // when int savedId = examService.addExam(requestExamDto); -// then + // then verify(examTypeRepository, times(1)).findByName(requestExamDto.getExamType()); verify(timetableService, times(1)).getGeneralGroupList(); verify(groupRepository, times(1)).findAllByNameIn(combinedGroups); @@ -850,7 +867,11 @@ private void testExamServiceForSubgroups(Set generalGroups, Set @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()); + Set capturedGroups = groupCaptor + .getValue() + .stream() + .map(StudentGroup::getName) + .collect(Collectors.toSet()); assertEquals(combinedGroups, capturedGroups); ArgumentCaptor examCaptor = ArgumentCaptor.forClass(Exam.class); @@ -858,4 +879,5 @@ private void testExamServiceForSubgroups(Set generalGroups, Set 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/RequestExamDtoTest.java b/src/test/java/org/pkwmtt/calendar/exams/dto/RequestExamDtoTest.java similarity index 79% rename from src/test/java/org/pkwmtt/examCalendar/dto/RequestExamDtoTest.java rename to src/test/java/org/pkwmtt/calendar/exams/dto/RequestExamDtoTest.java index d203c05..487dfbb 100644 --- a/src/test/java/org/pkwmtt/examCalendar/dto/RequestExamDtoTest.java +++ b/src/test/java/org/pkwmtt/calendar/exams/dto/RequestExamDtoTest.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar.dto; +package org.pkwmtt.calendar.exams.dto; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validation; @@ -21,7 +21,7 @@ public RequestExamDtoTest() { @Test void shouldSuccessWithCompleteData() { -// given + //given RequestExamDto requestExamDto = RequestExamDto.builder() .title("Math exam") .description("First exam") @@ -30,13 +30,13 @@ void shouldSuccessWithCompleteData() { .generalGroups(Set.of("12K2")) .subgroups(Set.of("L04")) .build(); -// when, then + //when, then assertTrue(validator.validate(requestExamDto).isEmpty()); } @Test void shouldSuccessWithEmptyDescription() { -// given + //given RequestExamDto requestExamDto = RequestExamDto.builder() .title("Math exam") .description("") @@ -45,13 +45,13 @@ void shouldSuccessWithEmptyDescription() { .generalGroups(Set.of("12K2")) .subgroups(Set.of("L04")) .build(); -// when, then + //when, then assertTrue(validator.validate(requestExamDto).isEmpty()); } @Test void shouldSuccessWithBlankDescription() { -// given + //given RequestExamDto requestExamDto = RequestExamDto.builder() .title("Math exam") .date(LocalDateTime.now().plusDays(1)) @@ -59,13 +59,13 @@ void shouldSuccessWithBlankDescription() { .generalGroups(Set.of("12K2")) .subgroups(Set.of("L04")) .build(); -// when, then + //when, then assertTrue(validator.validate(requestExamDto).isEmpty()); } @Test void shouldSuccessWithBlankSubgroups() { -// given + //given RequestExamDto requestExamDto = RequestExamDto.builder() .title("Math exam") .description("First exam") @@ -73,13 +73,13 @@ void shouldSuccessWithBlankSubgroups() { .examType("exam") .generalGroups(Set.of("12K2")) .build(); -// when, then + //when, then assertTrue(validator.validate(requestExamDto).isEmpty()); } @Test void shouldSuccessWithEmptySubgroups() { -// given + //given RequestExamDto requestExamDto = RequestExamDto.builder() .title("Math exam") .description("First exam") @@ -88,7 +88,7 @@ void shouldSuccessWithEmptySubgroups() { .generalGroups(Set.of("12K2")) .subgroups(Set.of("")) .build(); -// when, then + //when, then assertTrue(validator.validate(requestExamDto).isEmpty()); } @@ -96,7 +96,7 @@ void shouldSuccessWithEmptySubgroups() { // empty Strings @Test void shouldFailWithEmptyTitle() { - // given + //given RequestExamDto requestExamDto = RequestExamDto.builder() .title("") .description("First exam") @@ -105,16 +105,16 @@ void shouldFailWithEmptyTitle() { .generalGroups(Set.of("12K2")) .subgroups(Set.of("")) .build(); -// when + // when Set> violations = validator.validate(requestExamDto); -// then + //then assertFalse(validator.validate(requestExamDto).isEmpty()); assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("title"))); } @Test void shouldFailWithBlankTitle() { - // given + //given RequestExamDto requestExamDto = RequestExamDto.builder() .description("First exam") .date(LocalDateTime.now().plusDays(1)) @@ -122,16 +122,16 @@ void shouldFailWithBlankTitle() { .generalGroups(Set.of("12K2")) .subgroups(Set.of("")) .build(); -// when + //when Set> violations = validator.validate(requestExamDto); -// then + //then assertFalse(validator.validate(requestExamDto).isEmpty()); assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("title"))); } @Test void shouldFailWithEmptyGeneralGroups() { - // given + //given RequestExamDto requestExamDto = RequestExamDto.builder() .title("Math exam") .description("First exam") @@ -140,16 +140,16 @@ void shouldFailWithEmptyGeneralGroups() { .generalGroups(Set.of()) .subgroups(Set.of("L04")) .build(); -// when + //when Set> violations = validator.validate(requestExamDto); -// then + //then assertFalse(validator.validate(requestExamDto).isEmpty()); assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("generalGroups"))); } @Test void shouldFailWithBlankGeneralGroups() { - // given + //given RequestExamDto requestExamDto = RequestExamDto.builder() .title("Math exam") .description("First exam") @@ -157,20 +157,18 @@ void shouldFailWithBlankGeneralGroups() { .examType("exam") .subgroups(Set.of("L04")) .build(); -// when + //when Set> violations = validator.validate(requestExamDto); -// then + //then assertFalse(validator.validate(requestExamDto).isEmpty()); assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("generalGroups"))); } -// to long Strings - @Test - void ShouldFailWithTooLongTitle() { - // given + void shouldFailWithTooLongTitle() { + //given RequestExamDto requestExamDto = RequestExamDto.builder() -// 256 characters + //256 characters .title("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") .description("First exam") .date(LocalDateTime.now().plusDays(1)) @@ -178,47 +176,81 @@ void ShouldFailWithTooLongTitle() { .generalGroups(Set.of("12K2")) .subgroups(Set.of("L04")) .build(); -// when + //when Set> violations = validator.validate(requestExamDto); -// then + //then assertFalse(validator.validate(requestExamDto).isEmpty()); assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("title"))); } @Test void toLongDescription() { - // given + //given RequestExamDto requestExamDto = RequestExamDto.builder() -// 256 characters .title("Math exam") + //256 characters .description("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") .date(LocalDateTime.now().plusDays(1)) .examType("exam") .generalGroups(Set.of("12K2")) .subgroups(Set.of("L04")) .build(); -// when + //when Set> violations = validator.validate(requestExamDto); -// then + //then assertFalse(validator.validate(requestExamDto).isEmpty()); assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("description"))); } + @Test + void dateIsNull() { + //given + RequestExamDto requestExamDto = RequestExamDto.builder() + .title("Math exam") + .description("Math exam") + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); + //when + Set> violations = validator.validate(requestExamDto); + //then + assertFalse(validator.validate(requestExamDto).isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("date"))); + } + @Test void dateNotInFuture() { - // given + //given RequestExamDto requestExamDto = RequestExamDto.builder() -// 256 characters .title("Math exam") - .description("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .description("Math exam") .date(LocalDateTime.now().minusHours(1)) .examType("exam") .generalGroups(Set.of("12K2")) .subgroups(Set.of("L04")) .build(); - // when + //when + Set> violations = validator.validate(requestExamDto); + //then + assertFalse(validator.validate(requestExamDto).isEmpty()); + assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("date"))); + } + + @Test + void dateToFarInFuture() { + //given + RequestExamDto requestExamDto = RequestExamDto.builder() + .title("Math exam") + .description("Math exam") + .date(LocalDateTime.now().plusDays(365)) + .examType("exam") + .generalGroups(Set.of("12K2")) + .subgroups(Set.of("L04")) + .build(); + //when Set> violations = validator.validate(requestExamDto); -// then + //then assertFalse(validator.validate(requestExamDto).isEmpty()); assertTrue(violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals("date"))); } diff --git a/src/test/java/org/pkwmtt/examCalendar/entity/ExamTest.java b/src/test/java/org/pkwmtt/calendar/exams/entity/ExamTest.java similarity index 98% rename from src/test/java/org/pkwmtt/examCalendar/entity/ExamTest.java rename to src/test/java/org/pkwmtt/calendar/exams/entity/ExamTest.java index d813ede..e732f12 100644 --- a/src/test/java/org/pkwmtt/examCalendar/entity/ExamTest.java +++ b/src/test/java/org/pkwmtt/calendar/exams/entity/ExamTest.java @@ -1,4 +1,4 @@ -package org.pkwmtt.examCalendar.entity; +package org.pkwmtt.calendar.exams.entity; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; diff --git a/src/test/java/org/pkwmtt/examCalendar/repository/ExamRepositoryTest.java b/src/test/java/org/pkwmtt/calendar/exams/repository/ExamRepositoryTest.java similarity index 55% rename from src/test/java/org/pkwmtt/examCalendar/repository/ExamRepositoryTest.java rename to src/test/java/org/pkwmtt/calendar/exams/repository/ExamRepositoryTest.java index e2dd528..97c5cda 100644 --- a/src/test/java/org/pkwmtt/examCalendar/repository/ExamRepositoryTest.java +++ b/src/test/java/org/pkwmtt/calendar/exams/repository/ExamRepositoryTest.java @@ -1,12 +1,12 @@ -package org.pkwmtt.examCalendar.repository; +package org.pkwmtt.calendar.exams.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.pkwmtt.calendar.exams.entity.Exam; +import org.pkwmtt.calendar.exams.entity.ExamType; +import org.pkwmtt.calendar.exams.entity.StudentGroup; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.EmbeddedDatabaseConnection; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; @@ -27,231 +27,225 @@ @AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2) @ActiveProfiles("database") class ExamRepositoryTest { - + @Autowired private ExamRepository examRepository; - + @Autowired private ExamTypeRepository examTypeRepository; - + @Autowired private GroupRepository groupRepository; - + private Integer ex1Id; private Integer ex2Id; - private Integer ex3Id; - private Integer ex4Id; - private Integer ex5Id; - private Integer ex6Id; - + @BeforeAll - void setUp() { + void setUp () { ExamType examType = ExamType.builder() - .name("exam").build(); + .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(); - + + 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(); - + .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(); - + .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(); - + .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)) - .build(); - + .title("general Group Exam 1") + .description("Linear Algebra") + .examDate(LocalDateTime.now().plusDays(1)) + .examType(examType) + .groups(Set.of(g12K1, g12K2)) + .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(); - + .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(); - + .title("general Group Exam 3") + .description("Linear Algebra") + .examDate(LocalDateTime.now().plusDays(1)) + .examType(examType) + .groups(Set.of(g12A1, g12A2)) + .build(); + ex1Id = examRepository.save(smallGroupExam1).getExamId(); ex2Id = examRepository.save(smallGroupExam2).getExamId(); - ex3Id = examRepository.save(smallGroupExam3).getExamId(); - ex4Id = examRepository.save(generalGroupExam1).getExamId(); - ex5Id = examRepository.save(generalGroupExam2).getExamId(); - ex6Id = examRepository.save(generalGroupExam3).getExamId(); + examRepository.save(smallGroupExam3); + examRepository.save(generalGroupExam1); + examRepository.save(generalGroupExam2); + examRepository.save(generalGroupExam3); } - + @Test - void shouldReturnExamsWhenNotAllSubgroupsFromRepositoryMatched() { - Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K", Set.of("12K3"), Set.of("L04")); + void shouldReturnExamsWhenNotAllSubgroupsFromRepositoryMatched () { + Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K", Set.of("12K3"), 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.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K", Set.of("12K3"), Set.of("L05")); + void shouldReturnExamWhenNotAllSubgroupsFromArgumentsMatchedAndNotReturnExamsForWrongGeneralGroup () { + Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K", Set.of("12K3"), 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.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K", Set.of("12K3"), Set.of("L04", "L05")); + void shouldReturnExamsWhenMultipleArgumentsMatch () { + Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K", Set.of("12K3"), 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 shouldReturnOnlyExamsForGeneralGroups() { - Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K", Set.of("12K1"), Set.of("L01", "L08")); + void shouldReturnOnlyExamsForGeneralGroups () { + Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K", Set.of("12K1"), Set.of("L01", "L08")); assertEquals(2, exams.size()); Set examTitles = exams.stream().map(Exam::getTitle).collect(Collectors.toSet()); assertTrue(examTitles.contains("general Group Exam 1")); assertTrue(examTitles.contains("general Group Exam 2")); } - + @Test - void shouldReturnGeneralGroupExamsWhenSubgroupsIsEmpty() { - Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K", Set.of("12K1"), Set.of()); + void shouldReturnGeneralGroupExamsWhenSubgroupsIsEmpty () { + Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K", Set.of("12K1"), Set.of()); assertEquals(2, exams.size()); Set examTitles = exams.stream().map(Exam::getTitle).collect(Collectors.toSet()); assertTrue(examTitles.contains("general Group Exam 1")); assertTrue(examTitles.contains("general Group Exam 2")); } - + @Test - void shouldReturnExamsForGeneralAndSubgroups() { - Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K", Set.of("12K2"), Set.of("L04", "L05")); + void shouldReturnExamsForGeneralAndSubgroups () { + Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K", Set.of("12K2"), Set.of("L04", "L05")); assertEquals(3, 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")); assertTrue(examTitles.contains("general Group Exam 1")); } - + @Test - void ShouldReturnEmptyListWhenSubgroupsSetIsEmpty() { - Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K", Set.of(), Set.of()); + void ShouldReturnEmptyListWhenSubgroupsSetIsEmpty () { + Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K", Set.of(), Set.of()); assertTrue(exams.isEmpty()); } - + @Test - void shouldReturnEmptyListWhenGeneralGroupIdentifierHasInvalidFormat() { - Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12K2", Set.of(), Set.of("L04")); + void shouldReturnEmptyListWhenGeneralGroupIdentifierHasInvalidFormat () { + Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12K2", Set.of(), Set.of("L04")); assertTrue(exams.isEmpty()); } - + @Test - void shouldReturnEmptyListWhenGeneralGroupIdentifierDontMatch() { - Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup("12B", Set.of("12B1"), Set.of("L04", "L05")); + void shouldReturnEmptyListWhenGeneralGroupIdentifierDontMatch () { + Set exams = examRepository.findAllBySubgroupsOfSuperiorGroupAndGeneralGroup( + "12B", Set.of("12B1"), Set.of("L04", "L05")); assertTrue(exams.isEmpty()); } - -// findCommonExamIdsForGroups - + + // findCommonExamIdsForGroups + @Test - void shouldReturnWhenThereAreMoreGroupsInRepositoryThanArguments() { -// when + void shouldReturnWhenThereAreMoreGroupsInRepositoryThanArguments () { + // when Set ids = examRepository.findCommonExamIdsForGroups(Set.of("12K", "L04"), 2); -// then + // then assertEquals(2, ids.size()); assertTrue(ids.contains(ex1Id)); assertTrue(ids.contains(ex2Id)); } - + @Test - void shouldReturnOnlyWhenAllGroupsMatch() { -// when + void shouldReturnOnlyWhenAllGroupsMatch () { + // when Set ids = examRepository.findCommonExamIdsForGroups(Set.of("12K", "L04", "L05"), 3); -// then + // then assertEquals(1, ids.size()); assertTrue(ids.contains(ex2Id)); } - + @Test - void shouldReturnEmptySetWhenArgumentListIsEmpty() { -// when + void shouldReturnEmptySetWhenArgumentListIsEmpty () { + // when Set ids = examRepository.findCommonExamIdsForGroups(Set.of(), 0); -// then + // then assertTrue(ids.isEmpty()); } - + @Test - void shouldReturnEmptySetWhenThereAreMoreArgumentsThanGroupsInRepository() { -// when - Set ids = examRepository.findCommonExamIdsForGroups(Set.of("12K", "L04", "L05","L06"), 4); -// then + void shouldReturnEmptySetWhenThereAreMoreArgumentsThanGroupsInRepository () { + // when + Set ids = examRepository.findCommonExamIdsForGroups(Set.of("12K", "L04", "L05", "L06"), 4); + // then assertTrue(ids.isEmpty()); } - + @Test - void shouldReturnEmptySetWhenSubgroupNotMatchSuperiorGroup() { -// when - Set ids = examRepository.findCommonExamIdsForGroups(Set.of("L04","G12A"), 2); -// then + void shouldReturnEmptySetWhenSubgroupNotMatchSuperiorGroup () { + // when + Set ids = examRepository.findCommonExamIdsForGroups(Set.of("L04", "G12A"), 2); + // then assertTrue(ids.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 deleted file mode 100644 index 10814aa..0000000 --- a/src/test/java/org/pkwmtt/otp/OTPServiceTest.java +++ /dev/null @@ -1,156 +0,0 @@ -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("test2@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("test2@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/security/authentication/authenticationProvider/ModeratorAuthenticationProviderTest.java b/src/test/java/org/pkwmtt/security/authentication/authenticationProvider/ModeratorAuthenticationProviderTest.java new file mode 100644 index 0000000..d426c29 --- /dev/null +++ b/src/test/java/org/pkwmtt/security/authentication/authenticationProvider/ModeratorAuthenticationProviderTest.java @@ -0,0 +1,77 @@ +package org.pkwmtt.security.authentication.authenticationProvider; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.pkwmtt.security.authentication.authenticationToken.JwtAuthenticationToken; +import org.pkwmtt.security.jwt.JwtService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ModeratorAuthenticationProviderTest { + + @Mock + private JwtService jwtService; + + @InjectMocks + private ModeratorAuthenticationProvider moderatorAuthenticationProvider; + + @Test + void shouldAuthenticateWhenTokenIsValid(){ +// given + Authentication auth = mock(JwtAuthenticationToken.class); + + when(auth.getCredentials()).thenReturn("token"); + when(jwtService.extractClaim(any(String.class),any())).thenReturn("ROLE_MODERATOR"); + when(jwtService.getSubject(any(String.class))).thenReturn("11111111-2222-3333-4444-555555555555"); + +// when + Authentication result = moderatorAuthenticationProvider.authenticate(auth); + +// then + assertTrue(result.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_MODERATOR"))); + assertTrue(result.isAuthenticated()); + assertEquals("11111111-2222-3333-4444-555555555555", result.getPrincipal().toString()); + assertNull(result.getCredentials()); + assertNull(((JwtAuthenticationToken) result).getSuperiorGroup()); + } + + @Test + void shouldReturnNullWhenReceivedStudentToken(){ +// given + Authentication auth = mock(JwtAuthenticationToken.class); + + when(auth.getCredentials()).thenReturn("token"); + when(jwtService.extractClaim(any(String.class),any())).thenReturn("ROLE_STUDENT"); + +// when + Authentication result = moderatorAuthenticationProvider.authenticate(auth); + +// then + assertNull(result); + } + + @Test + void shouldThrowWhenTokenExpired(){ + // TODO: + } + + @Test + void shouldThrowWhenSignatureIsInvalid(){ + // TODO: + } + + @Test + void shouldThrowWhenUnableToParseToken(){ + // TODO: + } + +} \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/security/authentication/authenticationProvider/StudentAuthenticationProviderTest.java b/src/test/java/org/pkwmtt/security/authentication/authenticationProvider/StudentAuthenticationProviderTest.java new file mode 100644 index 0000000..794ce2f --- /dev/null +++ b/src/test/java/org/pkwmtt/security/authentication/authenticationProvider/StudentAuthenticationProviderTest.java @@ -0,0 +1,80 @@ +package org.pkwmtt.security.authentication.authenticationProvider; + +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.pkwmtt.security.authentication.authenticationToken.JwtAuthenticationToken; +import org.pkwmtt.security.jwt.JwtService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.function.Function; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class StudentAuthenticationProviderTest { + + @Mock + private JwtService jwtService; + + @InjectMocks + private StudentAuthenticationProvider studentAuthenticationProvider; + + @Test + void shouldAuthenticateWhenTokenIsValid(){ +// given + Authentication auth = mock(JwtAuthenticationToken.class); + + when(auth.getCredentials()).thenReturn("token"); + doReturn("ROLE_STUDENT") + .when(jwtService).extractClaim(eq("token"), ArgumentMatchers.>any()); + when(jwtService.getSubject(any(String.class))).thenReturn("11111111-2222-3333-4444-555555555555"); + +// when + Authentication result = studentAuthenticationProvider.authenticate(auth); + +// then + assertTrue(result.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_STUDENT"))); + assertTrue(result.isAuthenticated()); + assertEquals("11111111-2222-3333-4444-555555555555", result.getPrincipal().toString()); + assertNull(result.getCredentials()); + assertEquals("ROLE_STUDENT", ((JwtAuthenticationToken) result).getSuperiorGroup()); + } + + @Test + void shouldReturnNullWhenReceivedModeratorToken(){ +// given + Authentication auth = mock(JwtAuthenticationToken.class); + when(auth.getCredentials()).thenReturn("token"); + when(jwtService.extractClaim(any(String.class),any())).thenReturn("ROLE_MODERATOR"); + +// when + Authentication result = studentAuthenticationProvider.authenticate(auth); + +// then + assertNull(result); + } + + @Test + void shouldThrowWhenTokenExpired(){ + // TODO: + } + + @Test + void shouldThrowWhenSignatureIsInvalid(){ + // TODO: + } + + @Test + void shouldThrowWhenUnableToParseToken(){ + // TODO: + } + +} \ No newline at end of file diff --git a/src/test/java/org/pkwmtt/security/jwt/JwtServiceTest.java b/src/test/java/org/pkwmtt/security/jwt/JwtServiceTest.java new file mode 100644 index 0000000..1300ceb --- /dev/null +++ b/src/test/java/org/pkwmtt/security/jwt/JwtServiceTest.java @@ -0,0 +1,130 @@ +package org.pkwmtt.security.jwt; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.pkwmtt.calendar.exams.entity.Representative; +import org.pkwmtt.calendar.enities.SuperiorGroup; +import org.pkwmtt.security.jwt.utils.JwtUtils; + +import java.util.Base64; +import java.util.Date; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JwtServiceTest { + + @Mock + private JwtUtils jwtUtils; + + @InjectMocks + private JwtService jwtService; + + @BeforeEach + void setUp() { + + 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); + } + + @Test + void generateAccessToken_shouldCreateNonEmptyModeratorAccessToken() { + Representative user = getExampleRepresentative(); + + when(jwtUtils.getExpirationMs()).thenReturn(TimeUnit.MINUTES.toMillis(5)); + + String token = jwtService.generateAccessToken(user); + assertNotNull(token); + assertFalse(token.isEmpty()); + } + + @Test + void getUserIdFromToken_shouldReturnCorrectId() { + Representative user = getExampleRepresentative(); + + when(jwtUtils.getExpirationMs()).thenReturn(TimeUnit.MINUTES.toMillis(5)); + + String token = jwtService.generateAccessToken(user); + String id = jwtService.getSubject(token); + assertEquals("11111111-2222-3333-4444-555555555555", id); + } + + @Test + void extractRoleFromToken_shouldReturnCorrectRole() { + Representative user = getExampleRepresentative(); + + when(jwtUtils.getExpirationMs()).thenReturn(TimeUnit.MINUTES.toMillis(5)); + + String token = jwtService.generateAccessToken(user); + String roleClaim = jwtService.extractClaim(token, claims -> claims.get("role", String.class)); + assertEquals("ROLE_STUDENT", roleClaim); + } + + @Test + void extractGroupFromToken_shouldReturnCorrectGroup() { + Representative user = getExampleRepresentative(); + + when(jwtUtils.getExpirationMs()).thenReturn(TimeUnit.MINUTES.toMillis(5)); + + String token = jwtService.generateAccessToken(user); + String groupClaim = jwtService.extractClaim(token, claims -> claims.get("group", String.class)); + assertEquals("GROUP1", groupClaim); + } + + @Test + void validateAccessToken_shouldReturnTrueForValidModeratorAccessToken() { + Representative user = getExampleRepresentative(); + + when(jwtUtils.getExpirationMs()).thenReturn(TimeUnit.MINUTES.toMillis(5)); + String token = jwtService.generateAccessToken(user); + + assertEquals("11111111-2222-3333-4444-555555555555", jwtService.getSubject(token)); + } + + @Test + void getUserEmailFromToken_shouldThrowExceptionForInvalidToken() { + String invalidToken = "invalid.token.value"; + assertThrows(JwtException.class, () -> jwtService.getSubject(invalidToken)); + } + + @Test + void shouldThrowWhenTokenExpired(){ + Representative user = getExampleRepresentative(); + + long pastExpiration = System.currentTimeMillis() - 1000; + String expiredToken = Jwts.builder() + .subject(user.getRepresentativeId().toString()) + .claim("group", user.getSuperiorGroup()) + .claim("role", "ROLE_REPRESENTATIVE") + .issuedAt(new Date(System.currentTimeMillis() - 2000)) + .expiration(new Date(pastExpiration)) + .signWith(jwtService.decodeSecretKey()) + .compact(); + + RuntimeException exception = assertThrows(ExpiredJwtException.class, () -> jwtService.getSubject(expiredToken)); + assertEquals("JWT expired", exception.getMessage().substring(0, 11)); + } + + private static Representative getExampleRepresentative() { + return Representative.builder() + .representativeId(UUID.fromString("11111111-2222-3333-4444-555555555555")) + .email("user@example.com") + .superiorGroup( + SuperiorGroup.builder().name("GROUP1").build() + ).build(); + } + +} diff --git a/src/test/java/org/pkwmtt/security/jwt/filter/JwtFilterTest.java b/src/test/java/org/pkwmtt/security/jwt/filter/JwtFilterTest.java new file mode 100644 index 0000000..fe9afa6 --- /dev/null +++ b/src/test/java/org/pkwmtt/security/jwt/filter/JwtFilterTest.java @@ -0,0 +1,47 @@ +package org.pkwmtt.security.jwt.filter; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.pkwmtt.security.filter.JwtFilter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JwtFilterTest { + + @Mock + private AuthenticationManager jwtAuthenticationManager; + + @InjectMocks + private JwtFilter jwtFilter; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void jwtFilterShouldDelegateAuthenticationManager() throws Exception { +// given + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer validToken"); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + +// when + jwtFilter.doFilter(request, response, filterChain); + +// then + verify(jwtAuthenticationManager, times(1)).authenticate(any()); + } +} diff --git a/src/test/java/org/pkwmtt/security/token/JwtServiceImplTest.java b/src/test/java/org/pkwmtt/security/token/JwtServiceImplTest.java deleted file mode 100644 index 4a972c6..0000000 --- a/src/test/java/org/pkwmtt/security/token/JwtServiceImplTest.java +++ /dev/null @@ -1,139 +0,0 @@ -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.getSubject(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.getSubject(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 deleted file mode 100644 index 8aeb142..0000000 --- a/src/test/java/org/pkwmtt/security/token/filter/JwtFilterTest.java +++ /dev/null @@ -1,59 +0,0 @@ -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 java.util.function.Function; - -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.getSubject("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)); - when(jwtService.extractClaim(any(String.class), any(Function.class))).thenReturn("ADMIN"); - jwtFilter.doFilterInternal(request, response, filterChain); - - assertNotNull(SecurityContextHolder.getContext().getAuthentication()); - } -} diff --git a/src/test/java/org/pkwmtt/studentCodes/StudentCodeServiceTest.java b/src/test/java/org/pkwmtt/studentCodes/StudentCodeServiceTest.java new file mode 100644 index 0000000..65c1e5f --- /dev/null +++ b/src/test/java/org/pkwmtt/studentCodes/StudentCodeServiceTest.java @@ -0,0 +1,187 @@ +package org.pkwmtt.studentCodes; + +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.junit5.GreenMailExtension; +import com.icegreen.greenmail.util.ServerSetupTest; +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.StudentCodeNotFoundException; +import org.pkwmtt.exceptions.WrongStudentCodeFormatException; +import org.pkwmtt.studentCodes.dto.StudentCodeRequest; +import org.pkwmtt.studentCodes.repository.StudentCodeRepository; +import org.pkwmtt.security.authentication.dto.JwtAuthenticationDto; +import org.pkwmtt.security.jwt.refreshToken.repository.UserRefreshTokenRepository; +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 StudentCodeServiceTest { + + @Autowired + private StudentCodeService studentCodeService; + + @Autowired + private StudentCodeRepository studentCodeRepository; + + @Autowired + private UserRefreshTokenRepository userRefreshTokenRepository; + + @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 StudentCodeRequest("test2@localhost", "12K")); + Pattern pattern = Pattern.compile("[A-Z0-9]{6}"); + //when + studentCodeService.sendStudentCode(requests); + //then + assertAll(() -> { + assertTrue(greenMail.waitForIncomingEmail(1)); + MimeMessage receivedMessage = greenMail.getReceivedMessages()[0]; + assertEquals("Kod Starosty 12K", receivedMessage.getSubject()); + assertEquals("test2@localhost", receivedMessage.getAllRecipients()[0].toString()); + Matcher matcher = pattern.matcher(Objects.requireNonNull(extractBody(receivedMessage))); + assertTrue(matcher.find()); + String code = matcher.group(0); + assertTrue(studentCodeRepository.existsByCode(code)); + }); + } + + @Test + void shouldAggregateFailuresAndContinueProcessingOtherRequests () throws Exception { + // given: first request is invalid (subgroup provided -> causes WrongArgumentException), + // second request is valid and should still be processed + List requests = List.of( + new StudentCodeRequest("bad@localhost", "12K1"), + new StudentCodeRequest("test3@localhost", "12K") + ); + + Pattern pattern = Pattern.compile("[A-Z0-9]{6}"); + + // when + var failures = studentCodeService.sendStudentCode(requests); + + // then - verify failure for the bad request was collected + assertFalse(failures.isEmpty()); + assertTrue(failures.stream().anyMatch(f -> f.superiorGroupName().contains("12K1"))); + + // verify valid request was processed: mail received and code persisted + assertTrue(greenMail.waitForIncomingEmail(15000,1)); + MimeMessage receivedMessage = greenMail.getReceivedMessages()[0]; + Matcher matcher = pattern.matcher(Objects.requireNonNull(extractBody(receivedMessage))); + assertTrue(matcher.find()); + String code = matcher.group(); + assertTrue(studentCodeRepository.existsByCode(code)); + } + + @Test + void shouldAggregateMultipleFailuresIntoSingleExceptionMessage () { + // given: both requests invalid (subgroups provided) + List requests = List.of( + new StudentCodeRequest("a@localhost", "12K1"), + new StudentCodeRequest("b@localhost", "34L2") + ); + + // when + var failures = studentCodeService.sendStudentCode(requests); + + // then - verify both failures were collected and contain group names and exception info + assertNotNull(failures); + assertEquals(2, failures.size(), "Expected two failures collected"); + + assertTrue(failures.stream().anyMatch(f -> f.superiorGroupName().equals("12K1") + && f.exceptionClass().equals("WrongArgumentException"))); + assertTrue(failures.stream().anyMatch(f -> f.superiorGroupName().equals("34L2") + && f.exceptionClass().equals("WrongArgumentException"))); + } + + @Test + void shouldGenerateTokenForRepresentative () throws Exception { + //given + List requests = List.of(new StudentCodeRequest("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 + studentCodeService.sendStudentCode(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"); + } + + JwtAuthenticationDto token = studentCodeService.generateTokenForUser(code); //generate token + + //then + assertAll(() -> { + assertNotNull(token); + + Matcher tokenMatcher = tokenPattern.matcher(token.getAccessToken()); + assertNotNull(token.getRefreshToken()); + assertTrue(tokenMatcher.find()); + assertFalse(userRefreshTokenRepository.findAll().isEmpty()); + }); + } + + @Test + void shouldThrow_WrongOTPFormatException_wrongCharacters () { + assertThrows(WrongStudentCodeFormatException.class, () -> studentCodeService.generateTokenForUser("XXXXX#")); + } + + @Test + void shouldThrow_WrongOTPFormatException_tooLongCode () { + assertThrows(WrongStudentCodeFormatException.class, () -> studentCodeService.generateTokenForUser("X".repeat(7))); + } + + @Test + void shouldThrow_OTPCodeNotFoundException () { + assertThrows( + StudentCodeNotFoundException.class, () -> studentCodeService.generateTokenForUser("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/timetable/TimetableCacheServiceTest.java b/src/test/java/org/pkwmtt/timetable/TimetableCacheServiceTest.java index 598ea30..2a1790f 100644 --- a/src/test/java/org/pkwmtt/timetable/TimetableCacheServiceTest.java +++ b/src/test/java/org/pkwmtt/timetable/TimetableCacheServiceTest.java @@ -1,5 +1,6 @@ package org.pkwmtt.timetable; +import com.fasterxml.jackson.core.JsonProcessingException; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; @@ -62,7 +63,7 @@ public void shouldHourListBePresentInCache () { @Test - public void shouldReturnGeneralGroupsMap () { + public void shouldReturnGeneralGroupsMap () throws JsonProcessingException { //given var expectedMap = Map.of( "11K2", @@ -85,7 +86,7 @@ public void shouldReturnGeneralGroupsMap () { } @Test - public void shouldGeneralGroupMapBePresentInCache () { + public void shouldGeneralGroupMapBePresentInCache () throws JsonProcessingException { //given var key = "generalGroupMap"; var expectedValue = "{\"11K2\":\"plany/o8.html\",\"12K1\":\"plany/o25.html\",\"11A1\":\"plany/o1.html\",\"12K3\":\"plany/o27.html\",\"12K2\":\"plany/o26.html\"}"; @@ -105,7 +106,7 @@ public void shouldGeneralGroupMapBePresentInCache () { } @Test - public void shouldReturn12K1Schedule () { + public void shouldReturn12K1Schedule () throws JsonProcessingException { //given var generalGroupName = "12K1"; // get random general group @@ -118,7 +119,7 @@ public void shouldReturn12K1Schedule () { } @Test - public void shouldRandomGeneralGroupScheduleBePresentInCache () { + public void shouldRandomGeneralGroupScheduleBePresentInCache () throws JsonProcessingException { //given String generalGroupName = "12K1"; // get random general group String key = "timetable_" + generalGroupName; diff --git a/src/test/java/org/pkwmtt/timetable/TimetableControllerTest.java b/src/test/java/org/pkwmtt/timetable/TimetableControllerTest.java index 1c4d195..3d8d6fc 100644 --- a/src/test/java/org/pkwmtt/timetable/TimetableControllerTest.java +++ b/src/test/java/org/pkwmtt/timetable/TimetableControllerTest.java @@ -4,7 +4,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.pkwmtt.ValuesForTest; -import org.pkwmtt.examCalendar.enums.SubjectType; +import org.pkwmtt.calendar.exams.enums.SubjectType; import org.pkwmtt.exceptions.dto.ErrorResponseDTO; import org.pkwmtt.timetable.dto.CustomSubjectFilterDTO; import org.pkwmtt.timetable.dto.SubjectDTO; @@ -71,7 +71,7 @@ public void testGetGeneralGroupScheduleFiltered_withOptionalParams () { assertNotNull(response.getBody()); var responseData = response.getBody().getData(); assertEquals(5, responseData.size()); - assertEquals(12, responseData.getFirst().getOdd().size()); + assertEquals(14, responseData.getFirst().getOdd().size()); assertEquals(6, responseData.getFirst().getEven().size()); } ); @@ -84,14 +84,14 @@ public void testGetGeneralGroupScheduleFiltered_withOptionalParamsAndCustomSubje "http://localhost:%s/pkwmtt/api/v1/timetables/12K1?sub=K01&sub=L01&sub=P01", port ); - List payload = List.of(new CustomSubjectFilterDTO("PKM", "12K1", "K04")); + List payload = List.of(new CustomSubjectFilterDTO("Mechatro", "12K1", "P04")); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); var expectedObject = new SubjectDTO() - .setName("PKM") - .setType(SubjectType.COMPUTER_LABORATORY) - .setClassroom("A227") - .setRowId(8) + .setName("Mechatro") + .setType(SubjectType.PROJECT) + .setClassroom("K227") + .setRowId(2) .setCustom(true); //when @@ -111,25 +111,38 @@ public void testGetGeneralGroupScheduleFiltered_withOptionalParamsAndCustomSubje }, () -> { assertNotNull(response.getBody()); + var responseData = response.getBody().getData(); - var subject_Monday_Nr10_Odd_Row8 = responseData + + var subjects_Monday_Nr3_Odd_Row2 = responseData .getFirst() .getOdd() .stream() - .filter(item -> item.getRowId() == 8).toList().getFirst(); - var subject_Monday_Nr11_Odd_Row9 = responseData + .filter(item -> item.getRowId() == 2) + .toList(); + + var subjects_Monday_Nr4_Odd_Row3 = responseData .getFirst() .getOdd() .stream() - .filter(item -> item.getRowId() == 9).toList().getFirst(); - assertEquals(subject_Monday_Nr10_Odd_Row8, expectedObject); - assertEquals(subject_Monday_Nr11_Odd_Row9, expectedObject.setRowId(9)); - var subject_Thursday_Nr3_Odd_Row2List = responseData - .get(3) + .filter(item -> item.getRowId() == 3) + .toList(); + + assertTrue(subjects_Monday_Nr3_Odd_Row2.contains(expectedObject)); + assertTrue(subjects_Monday_Nr4_Odd_Row3.contains(expectedObject.setRowId(3))); + + var subjects_Monday_Nr5_Odd_Row4 = responseData + .getFirst() .getOdd() .stream() - .filter(item -> item.getRowId() == 2).toList(); - assertEquals(0, subject_Thursday_Nr3_Odd_Row2List.size()); + .filter(item -> item.getRowId() == 4) + .toList(); + + assertTrue(subjects_Monday_Nr5_Odd_Row4 + .stream() + .filter(subject -> subject.getName().equals("Mechatro")) + .toList() + .isEmpty()); } ); } diff --git a/src/test/java/org/pkwmtt/timetable/TimetableServiceTest.java b/src/test/java/org/pkwmtt/timetable/TimetableServiceTest.java index 09b53d7..65ddbed 100644 --- a/src/test/java/org/pkwmtt/timetable/TimetableServiceTest.java +++ b/src/test/java/org/pkwmtt/timetable/TimetableServiceTest.java @@ -1,105 +1,59 @@ package org.pkwmtt.timetable; -import com.fasterxml.jackson.core.JsonProcessingException; +import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.pkwmtt.ValuesForTest; -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 test.TestConfig; -import java.util.ArrayList; import java.util.List; 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 TimetableServiceTest extends TestConfig { - + @Autowired - private TimetableService service; - + TimetableService timetableService; + @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))); - } - - @Test - public void shouldReturnAvailableSubGroups () throws JsonProcessingException { - //given - var generalGroupName = "12K1"; - var expectedResult = List.of("K01", "K04", "L01", "L02", "L04", "P01", "P04"); - - //when - var result = service.getAvailableSubGroups(generalGroupName); - - //then - assertThat(result).isEqualTo(expectedResult); - - } - - - @Test - public void shouldThrow_SpecifiedGeneralGroupDoesntExistsException () { - //given - var subgroups = List.of("K01", "L01"); - var generalGroupName = "77Z3"; - //when - - //then - assertThrows( - SpecifiedGeneralGroupDoesntExistsException.class, - () -> service.getFilteredGeneralGroupSchedule(generalGroupName, subgroups, new ArrayList<>()) - ); - } - - @Test - public void shouldThrow_SpecifiedSubGroupDoesntExistsException () { - //given - List subgroups = List.of("Z01", "XCD"); - String generalGroupName = "12K1"; - //when - - //then - assertThrows( - SpecifiedSubGroupDoesntExistsException.class, - () -> service.getFilteredGeneralGroupSchedule(generalGroupName, subgroups, new ArrayList<>()) - ); + public void setup() { + 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 - public void shouldReturnSortedGeneralGroupList () { - //given - var expectedResult = List.of("11A1", "11K2", "12K1", "12K2", "12K3"); - //when - var result = service.getGeneralGroupList(); - - //then - assertAll( - () -> assertNotNull(result), - () -> assertFalse(result.isEmpty()), - () -> assertEquals(expectedResult, result) - ); + public void testGetListOfCustomSubjects_filtersExcludedOnes() throws Exception { + // when + List result = timetableService.getListOfCustomSubjects("12K1"); + + // then + assertNotNull(result); + assertFalse(result.isEmpty(), "Expected some custom subjects to be present"); + + // excluded subjects should not be present + boolean containsNiemiecki = result.stream().anyMatch(s -> s.contains("niemiecki")); + boolean containsJAng = result.stream().anyMatch(s -> s.contains("J ang")); + boolean containsWFHala = result.stream().anyMatch(s -> s.contains("WF hala")); + + assertFalse(containsNiemiecki, "Result should not contain 'niemiecki' subjects"); + assertFalse(containsJAng, "Result should not contain 'J ang' subjects"); + assertFalse(containsWFHala, "Result should not contain 'WF hala' subjects"); + + // example of expected custom subject from the sample HTML (PKM W -> PKM) + assertTrue(result.contains("PKM"), "Expected PKM to be present among custom subjects"); } - - -} \ No newline at end of file +} diff --git a/src/test/java/org/pkwmtt/utils/UtilsServiceTest.java b/src/test/java/org/pkwmtt/utils/UtilsServiceTest.java new file mode 100644 index 0000000..e99b561 --- /dev/null +++ b/src/test/java/org/pkwmtt/utils/UtilsServiceTest.java @@ -0,0 +1,110 @@ +package org.pkwmtt.utils; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.pkwmtt.cache.CacheInspector; +import org.pkwmtt.security.config.NoSecurityConfig; +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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import test.TestConfig; + +import java.time.LocalDate; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureTestDatabase(connection = EmbeddedDatabaseConnection.H2) +@ActiveProfiles("database") +@ContextConfiguration(classes = NoSecurityConfig.class) +class UtilsServiceTest extends TestConfig { + + @Autowired + UtilsService utilsService; + + @Autowired + UtilsRepository repository; + + @Autowired + CacheManager cacheManager; + + @Autowired + CacheInspector cacheInspector; + + @BeforeEach + void setUp () { + // clear DB and cache before each test + repository.deleteAll(); + var cache = cacheManager.getCache("utils"); + if (cache != null) { + cache.clear(); + } + } + + @Test + void shouldReturnEmptyWhenMissing () { + Optional res = utilsService.getEndOfSemester(); + assertTrue(res.isEmpty(), "Expected empty Optional when endOfSemester not present"); + + var cache = cacheManager.getCache("utils"); + assertNotNull(cache); + assertNull(cache.get("endOfSemester", String.class)); + } + + @Test + void shouldSetAndCacheEndOfSemester () { + LocalDate date = LocalDate.of(2026, 2, 28); + + utilsService.setEndOfSemester(date); + + var prop = repository.findByKey("endOfSemester"); + assertTrue(prop.isPresent()); + assertThat(prop.get().getValue()).isEqualTo("2026-02-28"); + + Map cache = cacheInspector.getAllEntries("utils"); + assertTrue(cache.containsKey("endOfSemester")); + assertThat(cache.get("endOfSemester")).isEqualTo("2026-02-28"); + } + + @Test + void shouldRemoveEndOfSemester () { + // first set + LocalDate date = LocalDate.of(2026, 2, 28); + utilsService.setEndOfSemester(date); + + // now remove + utilsService.removeEndOfSemester(); + + assertFalse(repository.findByKey("endOfSemester").isPresent()); + + Map cache = cacheInspector.getAllEntries("utils"); + assertFalse(cache.containsKey("endOfSemester")); + } + + @Test + void corruptedValueEvictsCache () { + // insert malformed value directly into DB + UtilsProperty bad = new UtilsProperty("endOfSemester", "not-a-date", "date"); + repository.save(bad); + + // ensure cache is empty + var cache = cacheManager.getCache("utils"); + if (cache != null) { + cache.clear(); + } + // call getter - should attempt to parse, fail, evict and return empty + Optional res = utilsService.getEndOfSemester(); + assertTrue(res.isEmpty()); + } +} \ No newline at end of file diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index d598883..9086917 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -1,61 +1,145 @@ +DROP TABLE IF EXISTS events_superior_group; +DROP TABLE IF EXISTS events; +DROP TABLE IF EXISTS representatives; +DROP TABLE IF EXISTS student_codes; 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 superior_groups; +DROP TABLE IF EXISTS exam_types; DROP TABLE IF EXISTS student_groups; -DROP TABLE IF EXISTS general_group; +DROP TABLE IF EXISTS moderators; +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS admin_keys; +DROP TABLE IF EXISTS bug_reports; -CREATE TABLE exam_type ( - exam_type_id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL +DROP TABLE IF EXISTS utils_kv; + +CREATE TABLE superior_groups ( + superior_group_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255) NOT NULL +); + +CREATE TABLE representatives ( + representative_id VARCHAR(36) PRIMARY KEY, + superior_group_id INT NOT NULL, + email VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT fk_representatives_superior_group FOREIGN KEY (superior_group_id) + REFERENCES superior_groups (superior_group_id) ON DELETE CASCADE ); -CREATE TABLE general_group ( - general_group_id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL +CREATE TABLE student_codes ( + student_code_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + code VARCHAR(255) NOT NULL, + expire DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + superior_group_id INT NOT NULL, + usage_count INT, + usage_limit INT, + CONSTRAINT fk_student_codes_superior_group FOREIGN KEY (superior_group_id) + REFERENCES superior_groups (superior_group_id) ON DELETE CASCADE ); CREATE TABLE student_groups ( - group_id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE + group_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE +); + +CREATE TABLE exam_types ( + exam_type_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(255) NOT NULL ); 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 + exam_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description VARCHAR(255), + exam_date DATETIME NOT NULL, + exam_type_id INT NOT NULL, + CONSTRAINT fk_exams_exam_type FOREIGN KEY (exam_type_id) + REFERENCES exam_types (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 + exam_group_id INT GENERATED BY DEFAULT AS IDENTITY 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 events ( + event_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description VARCHAR(255), + start_date DATETIME, + end_date DATETIME +); + +CREATE TABLE events_superior_group ( + row_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + event_id INT NOT NULL, + superior_group_id INT NOT NULL, + CONSTRAINT fk_events_superior_group_event FOREIGN KEY (event_id) + REFERENCES events (event_id) ON DELETE CASCADE, + CONSTRAINT fk_events_superior_group_superior FOREIGN KEY (superior_group_id) + REFERENCES superior_groups (superior_group_id) ON DELETE CASCADE +); + +CREATE TABLE moderators ( + moderator_id VARCHAR(36) PRIMARY KEY, + password VARCHAR(255) NOT NULL, + role VARCHAR(50) +); + +CREATE TABLE api_keys ( + key_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "value" VARCHAR(255) NOT NULL, + description VARCHAR(255) +); + +CREATE TABLE admin_keys ( + key_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "value" VARCHAR(255) NOT NULL, + description VARCHAR(255) +); + +CREATE TABLE user_refresh_tokens ( + token_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + token CHAR(64) NOT NULL, + representative_id VARCHAR(36) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + CONSTRAINT uq_representative_refresh_token_token UNIQUE (token), + CONSTRAINT fk_representative_refresh_token_representative FOREIGN KEY (representative_id) + REFERENCES representatives (representative_id) ON DELETE CASCADE +); + +CREATE TABLE moderator_refresh_tokens ( + token_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + token CHAR(64) NOT NULL, + moderator_id VARCHAR(36) NOT NULL, + created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires DATETIME NOT NULL, + CONSTRAINT uq_moderator_refresh_token_token UNIQUE (token), + CONSTRAINT fk_moderator_refresh_token_moderator FOREIGN KEY (moderator_id) + REFERENCES moderators (moderator_id) ON DELETE CASCADE +); + +CREATE TABLE bug_reports ( + report_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + reporter_email VARCHAR(255), + description TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(50) NOT NULL DEFAULT 'OPEN' +); + + +CREATE TABLE utils_kv ( + id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + property_key VARCHAR(255) NOT NULL UNIQUE, + property_value VARCHAR(250), + value_type VARCHAR(50), + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); \ No newline at end of file