├── project-structure └── Whole-Project-Diagram.png ├── src ├── main │ ├── java │ │ └── com │ │ │ └── libraryman_api │ │ │ ├── member │ │ │ ├── Role.java │ │ │ ├── MemberRepository.java │ │ │ ├── dto │ │ │ │ ├── UpdatePasswordDto.java │ │ │ │ ├── UpdateMembersDto.java │ │ │ │ └── MembersDto.java │ │ │ ├── Members.java │ │ │ └── MemberController.java │ │ │ ├── notification │ │ │ ├── NotificationStatus.java │ │ │ ├── NotificationRepository.java │ │ │ ├── NotificationType.java │ │ │ └── Notifications.java │ │ │ ├── fine │ │ │ ├── FineRepository.java │ │ │ └── Fine.java │ │ │ ├── security │ │ │ ├── model │ │ │ │ ├── LoginResponse.java │ │ │ │ └── LoginRequest.java │ │ │ ├── config │ │ │ │ ├── PasswordEncoder.java │ │ │ │ └── WebConfiguration.java │ │ │ ├── services │ │ │ │ ├── CustomUserDetailsService.java │ │ │ │ ├── LoginService.java │ │ │ │ └── SignupService.java │ │ │ ├── controllers │ │ │ │ ├── SignupController.java │ │ │ │ ├── LogoutController.java │ │ │ │ └── LoginController.java │ │ │ └── jwt │ │ │ │ ├── JwtAuthenticationHelper.java │ │ │ │ └── JwtAuthenticationFilter.java │ │ │ ├── newsletter │ │ │ ├── NewsletterSubscriberRepository.java │ │ │ ├── NewsletterSubscriber.java │ │ │ ├── NewsletterController.java │ │ │ └── NewsletterService.java │ │ │ ├── LibrarymanApiApplication.java │ │ │ ├── email │ │ │ ├── EmailSender.java │ │ │ └── EmailService.java │ │ │ ├── book │ │ │ ├── BookRepository.java │ │ │ ├── Book.java │ │ │ ├── BookDto.java │ │ │ └── BookController.java │ │ │ ├── analytics │ │ │ ├── AnalyticsService.java │ │ │ └── AnalyticsController.java │ │ │ ├── borrowing │ │ │ ├── BorrowingRepository.java │ │ │ ├── Borrowings.java │ │ │ ├── BorrowingsDto.java │ │ │ └── BorrowingController.java │ │ │ └── exception │ │ │ ├── InvalidPasswordException.java │ │ │ ├── ErrorDetails.java │ │ │ ├── ResourceNotFoundException.java │ │ │ ├── InvalidSortFieldException.java │ │ │ └── GlobalExceptionHandler.java │ └── resources │ │ ├── application.properties │ │ ├── application-production.properties │ │ └── application-development.properties ├── it │ └── java │ │ └── com │ │ └── libraryman_api │ │ └── LibrarymanApiApplicationIntegrationTest.java └── test │ └── java │ └── com │ └── libraryman_api │ ├── security │ ├── services │ │ ├── CustomUserDetailsServiceTest.java │ │ └── LoginServiceTest.java │ └── jwt │ │ ├── JwtAuthenticationHelperTest.java │ │ └── JwtAuthenticationFilterTest.java │ ├── analytics │ └── AnalyticsServiceTest.java │ ├── email │ └── EmailServiceTest.java │ ├── newsletter │ └── NewsletterServiceTest.java │ ├── TestUtil.java │ └── book │ └── BookServiceTest.java ├── .gitignore ├── api-docs └── api-docs.md ├── .github ├── workflows │ ├── pr-checker.yml │ ├── greetings.yml │ └── build-test-lint-format.yml └── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature_request.yml ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── LICENSE ├── notification-docs ├── Account Details Updated 🔄.eml ├── Book Returned Successfully 📚.eml ├── Payment Received for Fine 💸.eml ├── Welcome to LibraryMan! 🎉.eml ├── Reminder_ Due date approaching ⏰.eml ├── Account Deletion Confirmation 🗑️.eml ├── Book Borrowed Successfully 🎉.eml └── Overdue Fine Imposed ‼️.eml ├── pom.xml ├── README.md └── Code_of_Conduct.md /project-structure/Whole-Project-Diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajaynegi45/LibraryMan-API/HEAD/project-structure/Whole-Project-Diagram.png -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/member/Role.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.member; 2 | 3 | public enum Role { 4 | ADMIN, LIBRARIAN, USER 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/notification/NotificationStatus.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.notification; 2 | 3 | public enum NotificationStatus { 4 | SENT, FAILED 5 | } 6 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.application.name=libraryman-api 2 | spring.profiles.active=${ENV:dev} 3 | jwt.secretKey=tmWZ/oEeCc4g8RqhD7EZUl05D7j69e2ROArNxtC8XfAIuV6CfNuA9OlAfgcRixmLXQPI2vgRM7M1Ydd2BK0a1g== -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/fine/FineRepository.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.fine; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.stereotype.Repository; 5 | 6 | @Repository 7 | public interface FineRepository extends JpaRepository { 8 | } 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/it/java/com/libraryman_api/LibrarymanApiApplicationIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class LibrarymanApiApplicationIntegrationTest { 8 | 9 | @Test 10 | void contextLoads() { 11 | 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/model/LoginResponse.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.model; 2 | 3 | 4 | public class LoginResponse { 5 | 6 | private String token; 7 | 8 | public LoginResponse(String token) { 9 | this.token = token; 10 | } 11 | 12 | public String getToken() { 13 | return token; 14 | } 15 | 16 | public void setToken(String token) { 17 | this.token = token; 18 | } 19 | 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/newsletter/NewsletterSubscriberRepository.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.newsletter; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | 5 | import java.util.Optional; 6 | 7 | public interface NewsletterSubscriberRepository extends JpaRepository { 8 | Optional findByEmail(String email); 9 | 10 | Optional findByUnsubscribeToken(String unsubscribeToken); 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/config/PasswordEncoder.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 6 | 7 | @Configuration 8 | public class PasswordEncoder { 9 | 10 | @Bean 11 | public BCryptPasswordEncoder bCryptPasswordEncoder() { 12 | return new BCryptPasswordEncoder(); 13 | } 14 | } -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/model/LoginRequest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.model; 2 | 3 | public class LoginRequest { 4 | 5 | private String username; 6 | 7 | private String password; 8 | 9 | 10 | public String getUsername() { 11 | return username; 12 | } 13 | 14 | public void setUsername(String username) { 15 | this.username = username; 16 | } 17 | 18 | public String getPassword() { 19 | return password; 20 | } 21 | 22 | public void setPassword(String password) { 23 | this.password = password; 24 | } 25 | 26 | 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | /bin/ 16 | 17 | ### IntelliJ IDEA ### 18 | .idea 19 | *.iws 20 | *.iml 21 | *.ipr 22 | 23 | ### NetBeans ### 24 | /nbproject/private/ 25 | /nbbuild/ 26 | /dist/ 27 | /nbdist/ 28 | /.nb-gradle/ 29 | build/ 30 | !**/src/main/**/build/ 31 | !**/src/test/**/build/ 32 | 33 | ### VS Code ### 34 | .vscode/ 35 | 36 | .DS_Store 37 | 38 | src/main/resources/application-dev.properties 39 | 40 | .github/workflows/maven-publish.yml 41 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/LibrarymanApiApplication.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.cache.annotation.EnableCaching; 6 | import org.springframework.scheduling.annotation.EnableAsync; 7 | import org.springframework.scheduling.annotation.EnableScheduling; 8 | 9 | @SpringBootApplication 10 | @EnableAsync(proxyTargetClass = true) 11 | @EnableScheduling 12 | @EnableCaching(proxyTargetClass = true) 13 | public class LibrarymanApiApplication { 14 | 15 | public static void main(String[] args) { 16 | SpringApplication.run(LibrarymanApiApplication.class, args); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/notification/NotificationRepository.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.notification; 2 | 3 | import com.libraryman_api.borrowing.Borrowings; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.Query; 6 | import org.springframework.data.repository.query.Param; 7 | import org.springframework.stereotype.Repository; 8 | 9 | import java.util.Date; 10 | import java.util.List; 11 | 12 | @Repository 13 | public interface NotificationRepository extends JpaRepository { 14 | List findByMember_memberId(int memberId); 15 | 16 | @Query("SELECT b FROM Borrowings b WHERE b.dueDate BETWEEN :today AND :twoDaysFromNow") 17 | List findBorrowingsDueInDays(@Param("today") Date today, @Param("twoDaysFromNow") Date twoDaysFromNow); 18 | } 19 | 20 | -------------------------------------------------------------------------------- /api-docs/api-docs.md: -------------------------------------------------------------------------------- 1 | # Library Management System API Documentation 2 | 3 |
4 | 5 | #### Base URL 6 | 7 | ``` 8 | http://localhost:8080/api 9 | ``` 10 | --- 11 | 12 | # Introduction 13 | This documentation provides a clear and concise guide to the available API endpoints, including their respective methods, success responses, and potential error responses. 14 | 15 |
16 | 17 | ### Global Error Handling 18 | 19 | All endpoints will return a standardized error response in case of exceptions: 20 | 21 | **Error Response Structure:** 22 | ```json 23 | { 24 | "timestamp": "2024-08-29T10:00:00Z", 25 | "message": "Error Message", 26 | "details": "Request Path" 27 | } 28 | ``` 29 | 30 | ### Common Error Codes 31 | 32 | - **404 NOT FOUND:** The requested resource was not found. 33 | - **400 BAD REQUEST:** The request was invalid or cannot be otherwise served. 34 | 35 | 36 | 37 |
38 |
39 |
40 |
41 |
-------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/services/CustomUserDetailsService.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.services; 2 | 3 | import com.libraryman_api.member.MemberRepository; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.security.core.userdetails.UserDetails; 6 | import org.springframework.security.core.userdetails.UserDetailsService; 7 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 8 | import org.springframework.stereotype.Service; 9 | 10 | @Service 11 | public class CustomUserDetailsService implements UserDetailsService { 12 | 13 | @Autowired 14 | MemberRepository memberRepository; 15 | 16 | @Override 17 | public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { 18 | 19 | return memberRepository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException("Username not Found")); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/member/MemberRepository.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.member; 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository; 4 | import org.springframework.data.jpa.repository.Query; 5 | import org.springframework.stereotype.Repository; 6 | 7 | import java.util.List; 8 | import java.util.Map; 9 | import java.util.Optional; 10 | 11 | @Repository 12 | public interface MemberRepository extends JpaRepository { 13 | 14 | Optional findByMemberId(int memberId); 15 | 16 | Optional findByUsername(String username); 17 | 18 | @Query(value = "SELECT m.name AS memberName, COUNT(b.borrowing_id) AS borrowCount, " + 19 | "SUM(CASE WHEN b.return_date IS NULL THEN 1 ELSE 0 END) AS currentBorrowings " + 20 | "FROM members m LEFT JOIN borrowings b ON m.member_id = b.member_id " + 21 | "GROUP BY m.member_id ORDER BY borrowCount DESC", nativeQuery = true) 22 | List> getMemberActivityReport(); 23 | } -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/notification/NotificationType.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.notification; 2 | 3 | 4 | /** 5 | * Represents the types of notifications available in the system. 6 | */ 7 | public enum NotificationType { 8 | /** 9 | * Used for account creation notifications. 10 | */ 11 | ACCOUNT_CREATED, 12 | 13 | /** 14 | * Used for account deletion notifications. 15 | */ 16 | ACCOUNT_DELETED, 17 | 18 | /** 19 | * Sent when a user borrows a book. 20 | */ 21 | BORROW, 22 | 23 | /** 24 | * Sent to remind user for due date 25 | */ 26 | REMINDER, 27 | 28 | /** 29 | * Indicates that a user has paid a fine. 30 | */ 31 | PAID, 32 | 33 | /** 34 | * Imposed when a user incurs a fine. 35 | */ 36 | FINE, 37 | 38 | /** 39 | * Used when updating user details. 40 | */ 41 | UPDATE, 42 | 43 | /** 44 | * Sent when a user returns a borrowed book. 45 | */ 46 | RETURNED 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/pr-checker.yml: -------------------------------------------------------------------------------- 1 | name: PR Issue Checker 2 | # Created my @smog-root. 3 | on: 4 | pull_request: 5 | types: [opened, edited] 6 | 7 | jobs: 8 | check_pr_description: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | 15 | - name: Check PR Description 16 | id: check_pr_description 17 | run: | 18 | PR_DESCRIPTION="${{ github.event.pull_request.body }}" 19 | if [[ -z "$PR_DESCRIPTION" ]]; then 20 | echo "PR description is missing." 21 | exit 1 22 | fi 23 | 24 | if [[ ! "$PR_DESCRIPTION" =~ Fixes\ #[0-9]+ ]]; then 25 | echo "The PR description should include 'Fixes #' if not addressing any issue." 26 | echo "##[error]Fixes #NEW must be included in the description." 27 | exit 1 28 | fi 29 | 30 | echo "PR description is valid." 31 | 32 | - name: Output result 33 | run: echo "All checks passed." 34 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # https://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip 20 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/email/EmailSender.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.email; 2 | 3 | import com.libraryman_api.notification.Notifications; 4 | 5 | /** 6 | * Interface representing an email sending service for the Library Management System. 7 | * Classes implementing this interface are responsible for sending emails 8 | * and updating the status of notifications in the system. 9 | */ 10 | public interface EmailSender { 11 | 12 | /** 13 | * Sends an email to the specified recipient with the given body, subject, and notification details. 14 | * 15 | * @param to the email address of the recipient 16 | * @param body the body of the email, typically in HTML or plain text format 17 | * @param subject the subject of the email 18 | * @param notification the notification entity associated with the email being sent, 19 | * used to track the status of the notification 20 | */ 21 | void send(String to, String body, String subject, Notifications notification); 22 | } 23 | -------------------------------------------------------------------------------- /src/main/resources/application-production.properties: -------------------------------------------------------------------------------- 1 | # --- Database Setup --- 2 | spring.datasource.url=${DATABASE_URL} 3 | spring.datasource.username=${DATABASE_USERNAME} 4 | spring.datasource.password=${DATABASE_PASSWORD} 5 | spring.datasource.driver-class-name=${DATABASE_DRIVER_CLASS_NAME} 6 | spring.jpa.hibernate.ddl-auto=update 7 | spring.jpa.show-sql=false 8 | 9 | # --- Mail Service Setup --- 10 | spring.mail.host=${MAIL_SERVICE_HOST} 11 | spring.mail.port=${MAIL_SERVICE_PORT} 12 | spring.mail.username=${MAIL_SERVICE_USERNAME} 13 | spring.mail.password=${MAIL_SERVICE_PASSWORD} 14 | spring.mail.properties.mail.smtp.auth=${MAIL_SERVICE_SMTP} 15 | spring.mail.properties.mail.starttls.enable=${MAIL_SERVICE_STARTTLS} 16 | spring.mail.properties.domain_name=${MAIL_SERVICE_DOMAIN_NAME} 17 | 18 | # --- Oauth 2.0 Configurations --- 19 | spring.security.oauth2.client.registration.google.client-name=google 20 | spring.security.oauth2.client.registration.google.client-id=${YOUR_CLIENT_ID} 21 | spring.security.oauth2.client.registration.google.client-secret=${YOUR_SECRET_KEY} 22 | spring.security.oauth2.client.registration.google.scope=email,profile -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ajay Negi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: 4 | pull_request_target: 5 | issues: 6 | 7 | jobs: 8 | greeting: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | steps: 14 | - name: Greet the user 15 | uses: actions/first-interaction@v1 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | issue-message: "👋 Thank you @${{ github.actor }} for raising an issue! We’re thrilled to have your input as we work to make this project even better. Our team will review it shortly, so stay tuned! Meanwhile, make sure your issue gets noticed, don’t forget to **star the repo 🌟** and follow [@ajaynegi45](https://github.com/ajaynegi45) for even more project insights!" 19 | pr-message: "🎉 Thank you @${{ github.actor }} for your contribution! Your pull request has been submitted successfully, and a maintainer will review it soon. We’re excited to have you on board! Remember to **star the repo 🌟** to help us grow, and follow [@ajaynegi45](https://github.com/ajaynegi45) to stay in the loop and increase the visibility of your contributions!" 20 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/controllers/SignupController.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.controllers; 2 | 3 | import com.libraryman_api.member.Members; 4 | import com.libraryman_api.security.services.SignupService; 5 | import org.springframework.security.access.prepost.PreAuthorize; 6 | import org.springframework.web.bind.annotation.PostMapping; 7 | import org.springframework.web.bind.annotation.RequestBody; 8 | import org.springframework.web.bind.annotation.RestController; 9 | 10 | 11 | @RestController 12 | public class SignupController { 13 | 14 | private final SignupService signupService; 15 | 16 | public SignupController(SignupService signupService) { 17 | this.signupService = signupService; 18 | } 19 | 20 | @PostMapping("/api/signup") 21 | public void signup(@RequestBody Members members) { 22 | this.signupService.signup(members); 23 | 24 | } 25 | 26 | @PostMapping("/api/signup/admin") 27 | @PreAuthorize("hasRole('ADMIN')") 28 | public void signupAdmin(@RequestBody Members members) { 29 | this.signupService.signupAdmin(members); 30 | } 31 | 32 | @PostMapping("/api/signup/librarian") 33 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN')") 34 | public void signupLibrarian(@RequestBody Members members) { 35 | this.signupService.signupLibrarian(members); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/controllers/LogoutController.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.controllers; 2 | 3 | import jakarta.servlet.http.Cookie; 4 | import jakarta.servlet.http.HttpServletRequest; 5 | import jakarta.servlet.http.HttpServletResponse; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.security.core.Authentication; 8 | import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; 9 | import org.springframework.web.bind.annotation.PostMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | @RestController 13 | public class LogoutController { 14 | 15 | @PostMapping("/api/logout") 16 | public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { 17 | if (authentication != null) { 18 | new SecurityContextLogoutHandler().logout(request, response, authentication); 19 | } 20 | removeAuthCookie(response); 21 | 22 | return ResponseEntity.ok("Successfully logged out."); 23 | } 24 | 25 | private void removeAuthCookie(HttpServletResponse response) { 26 | Cookie cookie = new Cookie("LibraryManCookie", null); 27 | cookie.setMaxAge(0); 28 | cookie.setPath("/"); 29 | cookie.setHttpOnly(true); 30 | cookie.setSecure(true); 31 | response.addCookie(cookie); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/book/BookRepository.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.book; 2 | 3 | import org.springframework.data.domain.Page; 4 | import org.springframework.data.domain.Pageable; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.data.jpa.repository.Query; 7 | import org.springframework.data.repository.query.Param; 8 | import org.springframework.stereotype.Repository; 9 | 10 | @Repository 11 | public interface BookRepository extends JpaRepository { 12 | 13 | /** 14 | * This method use SQL Query for finding book based on 15 | * title, author, genre, publishedYear, etc. By using LIKE operator 16 | * it search from database based on keyword entered. 17 | * 18 | * @param keyword 19 | * @param pageable 20 | * @return 21 | */ 22 | 23 | @Query("SELECT b FROM Book b WHERE " 24 | + "(LOWER(b.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " 25 | + "LOWER(b.author) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " 26 | + "LOWER(b.publisher) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " 27 | + "LOWER(b.genre) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " 28 | + "CAST(b.publishedYear AS string) LIKE %:keyword% OR " 29 | + "CAST(b.copiesAvailable AS string) LIKE %:keyword%)") 30 | Page searchBook(@Param("keyword") String keyword, Pageable pageable); 31 | } 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/member/dto/UpdatePasswordDto.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.member.dto; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | import jakarta.validation.constraints.Pattern; 5 | import jakarta.validation.constraints.Size; 6 | 7 | public class UpdatePasswordDto { 8 | private String currentPassword; 9 | 10 | @NotBlank(message = "New Password is required") 11 | @Size(min = 8, message = "Password must be at least 8 characters long") 12 | @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[@#$%^&+=]).*$", 13 | message = "Password must contain at least one letter, one number, and one special character") 14 | private String newPassword; 15 | 16 | public UpdatePasswordDto(String currentPassword, String newPassword) { 17 | this.currentPassword = currentPassword; 18 | this.newPassword = newPassword; 19 | } 20 | 21 | public UpdatePasswordDto() { 22 | } 23 | 24 | public String getCurrentPassword() { 25 | return currentPassword; 26 | } 27 | 28 | public void setCurrentPassword(String currentPassword) { 29 | this.currentPassword = currentPassword; 30 | } 31 | 32 | public String getNewPassword() { 33 | return newPassword; 34 | } 35 | 36 | public void setNewPassword(String newPassword) { 37 | this.newPassword = newPassword; 38 | } 39 | 40 | @Override 41 | public String toString() { 42 | return "UpdatePasswordDto{" + 43 | "currentPassword='" + currentPassword + '\'' + 44 | ", newPassword='" + newPassword + '\'' + 45 | '}'; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/newsletter/NewsletterSubscriber.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.newsletter; 2 | 3 | import jakarta.persistence.*; 4 | 5 | import java.util.UUID; 6 | 7 | @Entity 8 | @Table(name = "newsletter_subscribers") 9 | public class NewsletterSubscriber { 10 | 11 | @Id 12 | @GeneratedValue(strategy = GenerationType.IDENTITY) 13 | private Long id; 14 | 15 | @Column(nullable = false, unique = true) 16 | private String email; 17 | 18 | @Column(nullable = false) 19 | private boolean active = true; 20 | 21 | @Column(name = "unsubscribe_token", nullable = false, unique = true) 22 | private String unsubscribeToken; 23 | 24 | // Default constructor initializing unsubscribe token 25 | public NewsletterSubscriber() { 26 | this.unsubscribeToken = UUID.randomUUID().toString(); 27 | } 28 | 29 | // Constructor initializing with email 30 | public NewsletterSubscriber(String email) { 31 | this(); 32 | this.email = email; 33 | } 34 | 35 | // Getters and Setters 36 | public Long getId() { 37 | return id; 38 | } 39 | 40 | public String getEmail() { 41 | return email; 42 | } 43 | 44 | public void setEmail(String email) { 45 | this.email = email; 46 | } 47 | 48 | public boolean isActive() { 49 | return active; 50 | } 51 | 52 | public void setActive(boolean active) { 53 | this.active = active; 54 | } 55 | 56 | public String getUnsubscribeToken() { 57 | return unsubscribeToken; 58 | } 59 | 60 | public void regenerateToken() { 61 | this.unsubscribeToken = UUID.randomUUID().toString(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/controllers/LoginController.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.controllers; 2 | 3 | import com.libraryman_api.security.model.LoginRequest; 4 | import com.libraryman_api.security.model.LoginResponse; 5 | import com.libraryman_api.security.services.LoginService; 6 | import jakarta.servlet.http.Cookie; 7 | import jakarta.servlet.http.HttpServletResponse; 8 | import org.springframework.http.HttpStatus; 9 | import org.springframework.http.ResponseEntity; 10 | import org.springframework.web.bind.annotation.PostMapping; 11 | import org.springframework.web.bind.annotation.RequestBody; 12 | import org.springframework.web.bind.annotation.RestController; 13 | 14 | @RestController 15 | public class LoginController { 16 | 17 | private final LoginService loginService; 18 | 19 | public LoginController(LoginService loginService) { 20 | this.loginService = loginService; 21 | } 22 | 23 | @PostMapping("/api/login") 24 | public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) { 25 | LoginResponse loginResponse = loginService.login(loginRequest); 26 | 27 | if (loginResponse != null) { 28 | setAuthCookie(response); 29 | return new ResponseEntity<>(loginResponse, HttpStatus.OK); 30 | } else { 31 | return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); 32 | } 33 | } 34 | 35 | private void setAuthCookie(HttpServletResponse response) { 36 | Cookie cookie = new Cookie("LibraryManCookie", "libraryman_cookie"); 37 | cookie.setMaxAge(3600); // (3600 seconds) 38 | cookie.setPath("/"); 39 | response.addCookie(cookie); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/analytics/AnalyticsService.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.analytics; 2 | 3 | import com.libraryman_api.book.BookRepository; 4 | import com.libraryman_api.borrowing.BorrowingRepository; 5 | import com.libraryman_api.member.MemberRepository; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.time.LocalDate; 9 | import java.util.HashMap; 10 | import java.util.List; 11 | import java.util.Map; 12 | 13 | @Service 14 | public class AnalyticsService { 15 | 16 | private final BookRepository bookRepository; 17 | private final BorrowingRepository borrowingRepository; 18 | private final MemberRepository memberRepository; 19 | 20 | public AnalyticsService(BookRepository bookRepository, BorrowingRepository borrowingRepository, MemberRepository memberRepository) { 21 | this.bookRepository = bookRepository; 22 | this.borrowingRepository = borrowingRepository; 23 | this.memberRepository = memberRepository; 24 | } 25 | 26 | public Map getLibraryOverview() { 27 | Map overview = new HashMap<>(); 28 | overview.put("totalBooks", bookRepository.count()); 29 | overview.put("totalMembers", memberRepository.count()); 30 | overview.put("totalBorrowings", borrowingRepository.count()); 31 | return overview; 32 | } 33 | 34 | public List> getPopularBooks(int limit) { 35 | return borrowingRepository.findMostBorrowedBooks(limit); 36 | } 37 | 38 | public Map getBorrowingTrends(LocalDate startDate, LocalDate endDate) { 39 | return borrowingRepository.getBorrowingTrendsBetweenDates(startDate, endDate); 40 | } 41 | 42 | public List> getMemberActivityReport() { 43 | return memberRepository.getMemberActivityReport(); 44 | } 45 | } -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/analytics/AnalyticsController.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.analytics; 2 | 3 | import org.springframework.format.annotation.DateTimeFormat; 4 | import org.springframework.http.ResponseEntity; 5 | import org.springframework.web.bind.annotation.GetMapping; 6 | import org.springframework.web.bind.annotation.RequestMapping; 7 | import org.springframework.web.bind.annotation.RequestParam; 8 | import org.springframework.web.bind.annotation.RestController; 9 | 10 | import java.time.LocalDate; 11 | import java.util.List; 12 | import java.util.Map; 13 | 14 | @RestController 15 | @RequestMapping("/api/analytics") 16 | public class AnalyticsController { 17 | 18 | private final AnalyticsService analyticsService; 19 | 20 | public AnalyticsController(AnalyticsService analyticsService) { 21 | this.analyticsService = analyticsService; 22 | } 23 | 24 | @GetMapping("/overview") 25 | public ResponseEntity> getLibraryOverview() { 26 | return ResponseEntity.ok(analyticsService.getLibraryOverview()); 27 | } 28 | 29 | @GetMapping("/popular-books") 30 | public ResponseEntity>> getPopularBooks(@RequestParam(defaultValue = "10") int limit) { 31 | return ResponseEntity.ok(analyticsService.getPopularBooks(limit)); 32 | } 33 | 34 | @GetMapping("/borrowing-trends") 35 | public ResponseEntity> getBorrowingTrends( 36 | @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, 37 | @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate) { 38 | return ResponseEntity.ok(analyticsService.getBorrowingTrends(startDate, endDate)); 39 | } 40 | 41 | @GetMapping("/member-activity") 42 | public ResponseEntity>> getMemberActivityReport() { 43 | return ResponseEntity.ok(analyticsService.getMemberActivityReport()); 44 | } 45 | } -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/member/dto/UpdateMembersDto.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.member.dto; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | import jakarta.validation.constraints.Pattern; 5 | import jakarta.validation.constraints.Size; 6 | 7 | public class UpdateMembersDto { 8 | @NotBlank(message = "Name is required") 9 | @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") 10 | private String name; 11 | @NotBlank(message = "Username is required") 12 | @Size(min = 4, max = 50, message = "Username must be between 4 and 50 characters") 13 | private String username; 14 | @Pattern(regexp = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$", message = "Please enter a valid email address (e.g., user@example.com)") 15 | @NotBlank(message = "Email field cannot be empty. Please provide a valid email address.") 16 | private String email; 17 | 18 | public UpdateMembersDto(String name, String username, String email) { 19 | this.name = name; 20 | this.username = username; 21 | this.email = email; 22 | } 23 | 24 | public UpdateMembersDto() { 25 | } 26 | 27 | public String getName() { 28 | return name; 29 | } 30 | 31 | public void setName(String name) { 32 | this.name = name; 33 | } 34 | 35 | public String getUsername() { 36 | return username; 37 | } 38 | 39 | public void setUsername(String username) { 40 | this.username = username; 41 | } 42 | 43 | public String getEmail() { 44 | return email; 45 | } 46 | 47 | public void setEmail(String email) { 48 | this.email = email; 49 | } 50 | 51 | @Override 52 | public String toString() { 53 | return "UpdateMembersDto{" + 54 | "name='" + name + '\'' + 55 | ", username='" + username + '\'' + 56 | ", email='" + email + '\'' + 57 | '}'; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/test/java/com/libraryman_api/security/services/CustomUserDetailsServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.services; 2 | 3 | import com.libraryman_api.TestUtil; 4 | import com.libraryman_api.member.MemberRepository; 5 | import com.libraryman_api.member.Members; 6 | import org.junit.jupiter.api.Nested; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.mockito.InjectMocks; 10 | import org.mockito.Mock; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | import org.springframework.security.core.userdetails.UserDetails; 13 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 14 | 15 | import java.util.Optional; 16 | 17 | import static org.junit.jupiter.api.Assertions.assertEquals; 18 | import static org.junit.jupiter.api.Assertions.assertThrows; 19 | import static org.mockito.ArgumentMatchers.any; 20 | import static org.mockito.Mockito.when; 21 | 22 | /** 23 | * Tests the {@link CustomUserDetailsService}. 24 | */ 25 | @ExtendWith(MockitoExtension.class) 26 | class CustomUserDetailsServiceTest { 27 | @Mock 28 | private MemberRepository memberRepository; 29 | @InjectMocks 30 | private CustomUserDetailsService customUserDetailsService; 31 | 32 | @Nested 33 | class LoadUserByUsername { 34 | @Test 35 | void success() { 36 | Members members = TestUtil.getMembers(); 37 | when(memberRepository.findByUsername(any())).thenReturn(Optional.of(members)); 38 | 39 | UserDetails userDetails = customUserDetailsService.loadUserByUsername(members.getUsername()); 40 | 41 | assertEquals(members, userDetails); 42 | } 43 | 44 | @Test 45 | void notFound() { 46 | when(memberRepository.findByUsername(any())).thenReturn(Optional.empty()); 47 | 48 | assertThrows(UsernameNotFoundException.class, () -> customUserDetailsService.loadUserByUsername("username")); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/jwt/JwtAuthenticationHelper.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.jwt; 2 | 3 | import io.jsonwebtoken.Claims; 4 | import io.jsonwebtoken.Jwts; 5 | import io.jsonwebtoken.SignatureAlgorithm; 6 | import org.springframework.beans.factory.annotation.Value; 7 | import org.springframework.security.core.userdetails.UserDetails; 8 | import org.springframework.stereotype.Component; 9 | 10 | import javax.crypto.spec.SecretKeySpec; 11 | import java.util.Date; 12 | import java.util.HashMap; 13 | import java.util.Map; 14 | 15 | @Component 16 | public class JwtAuthenticationHelper { 17 | 18 | private static final long JWT_TOKEN_VALIDITY = 60 * 60; 19 | private final String secret; 20 | 21 | public JwtAuthenticationHelper(@Value("${jwt.secretKey}") String secret) { 22 | this.secret = secret; 23 | } 24 | 25 | public String getUsernameFromToken(String token) { 26 | String username = getClaimsFromToken(token).getSubject(); 27 | return username; 28 | } 29 | 30 | public Claims getClaimsFromToken(String token) { 31 | Claims claims = Jwts.parserBuilder() 32 | .setSigningKey(secret.getBytes()) 33 | .build().parseClaimsJws(token).getBody(); 34 | return claims; 35 | } 36 | 37 | public Boolean isTokenExpired(String token) { 38 | Claims claims = getClaimsFromToken(token); 39 | Date expDate = claims.getExpiration(); 40 | return expDate.before(new Date()); 41 | } 42 | 43 | public String generateToken(UserDetails userDetails) { 44 | Map claims = new HashMap<>(); 45 | return Jwts.builder().setClaims(claims).setSubject(userDetails.getUsername()) 46 | .setIssuedAt(new Date(System.currentTimeMillis())) 47 | .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000)) 48 | .signWith(new SecretKeySpec(secret.getBytes(), SignatureAlgorithm.HS512.getJcaName()), SignatureAlgorithm.HS512) 49 | .compact(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/borrowing/BorrowingRepository.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.borrowing; 2 | 3 | 4 | import com.libraryman_api.exception.InvalidSortFieldException; 5 | import com.libraryman_api.exception.ResourceNotFoundException; 6 | import org.springframework.data.domain.Page; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.jpa.repository.JpaRepository; 9 | import org.springframework.data.jpa.repository.Query; 10 | import org.springframework.data.mapping.PropertyReferenceException; 11 | import org.springframework.stereotype.Repository; 12 | 13 | import java.time.LocalDate; 14 | import java.util.List; 15 | import java.util.Map; 16 | 17 | @Repository 18 | public interface BorrowingRepository extends JpaRepository { 19 | 20 | // { 21 | // try { 22 | // Page borrowings = borrowingRepository.findByMember_memberId(memberId, pageable); 23 | // 24 | // if (borrowings.isEmpty()) { 25 | // throw new ResourceNotFoundException("Member didn't borrow any book"); 26 | // } 27 | // return borrowings.map(this::EntityToDto); 28 | // } catch (PropertyReferenceException ex) { 29 | // throw new InvalidSortFieldException("The specified 'sortBy' value is invalid."); 30 | // } 31 | // } 32 | 33 | Page findByMember_memberId(int memberId, Pageable pageable); 34 | 35 | @Query(value = "SELECT b.title AS bookTitle, COUNT(*) AS borrowCount " + 36 | "FROM borrowings br JOIN books b ON br.book_id = b.book_id " + 37 | "GROUP BY b.book_id ORDER BY borrowCount DESC LIMIT :limit", nativeQuery = true) 38 | List> findMostBorrowedBooks(int limit); 39 | 40 | @Query("SELECT FUNCTION('DATE', b.borrowDate) as date, COUNT(*) as count " + 41 | "FROM Borrowings b " + 42 | "WHERE b.borrowDate BETWEEN :startDate AND :endDate " + 43 | "GROUP BY FUNCTION('DATE', b.borrowDate)") 44 | Map getBorrowingTrendsBetweenDates(LocalDate startDate, LocalDate endDate); 45 | } 46 | -------------------------------------------------------------------------------- /src/main/resources/application-development.properties: -------------------------------------------------------------------------------- 1 | ## Database Setup First 2 | 3 | 4 | ## Create a Database and add database name where "Add_Your_Database_Name" is below 5 | ## Make this file as it was earlier before commiting code 6 | # Change this connection string to this format: jdbc:mysql://{ip_address}:{port}/{database_name} 7 | spring.datasource.url=jdbc:mysql://localhost:3306/Add_Your_Database_Name 8 | 9 | ## Add your Database Username and Password 10 | spring.datasource.username=Add_Your_UserName 11 | spring.datasource.password=Add_Your_Password 12 | 13 | # Hibernate Dialect for MySQL 8 14 | spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect 15 | 16 | # Prevent early database interaction 17 | spring.jpa.properties.hibernate.boot.allow_jdbc_metadata_access=false 18 | spring.jpa.hibernate.ddl-auto=update 19 | spring.sql.init.mode=never 20 | 21 | # Format SQL 22 | spring.jpa.properties.hibernate.format_sql=true 23 | 24 | 25 | # Error Handling 26 | server.error.include-binding-errors=always 27 | server.error.include-message=always 28 | server.error.include-stacktrace=never 29 | server.error.include-exception=true 30 | 31 | # Logging for Spring Security 32 | logging.level.org.springframework.security=TRACE 33 | 34 | 35 | 36 | # --- Mail Service Setup --- 37 | # I use docker mail service https://hub.docker.com/r/maildev/maildev 38 | # Or Web based Mail service https://mailtrap.io/ 39 | spring.mail.host=Add_Your_Mail_Service_Host 40 | spring.mail.port=Add_Your_Mail_Service_Port 41 | spring.mail.username=Add_Your_Mail_Service_Username 42 | spring.mail.password=Add_Your_Mail_Service_Password 43 | spring.mail.properties.mail.smtp.auth=Add_Your_Mail_Service_SMTP 44 | spring.mail.properties.mail.starttls.enable=Add_Your_Mail_Service_Start_TLS 45 | spring.mail.properties.domain_name=Add_Your_Mail_Service_Domain_Name 46 | 47 | 48 | 49 | # --- Oauth 2.0 Configurations --- 50 | spring.security.oauth2.client.registration.google.client-name=google 51 | spring.security.oauth2.client.registration.google.client-id=ADD_YOUR_CLIENT_ID 52 | spring.security.oauth2.client.registration.google.client-secret=ADD_YOUR_SECRET_KEY 53 | spring.security.oauth2.client.registration.google.scope=email,profile -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/services/LoginService.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.services; 2 | 3 | import com.libraryman_api.member.MemberRepository; 4 | import com.libraryman_api.security.jwt.JwtAuthenticationHelper; 5 | import com.libraryman_api.security.model.LoginRequest; 6 | import com.libraryman_api.security.model.LoginResponse; 7 | import org.springframework.security.authentication.AuthenticationManager; 8 | import org.springframework.security.authentication.BadCredentialsException; 9 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 10 | import org.springframework.security.core.userdetails.UserDetails; 11 | import org.springframework.security.core.userdetails.UserDetailsService; 12 | import org.springframework.stereotype.Service; 13 | 14 | 15 | @Service 16 | public class LoginService { 17 | 18 | private final AuthenticationManager authenticationManager; 19 | 20 | private final UserDetailsService userDetailsService; 21 | 22 | private final JwtAuthenticationHelper jwtHelper; 23 | 24 | private final MemberRepository memberRepository; 25 | 26 | public LoginService(AuthenticationManager authenticationManager, UserDetailsService userDetailsService, JwtAuthenticationHelper jwtHelper, MemberRepository memberRepository) { 27 | this.authenticationManager = authenticationManager; 28 | this.userDetailsService = userDetailsService; 29 | this.jwtHelper = jwtHelper; 30 | this.memberRepository = memberRepository; 31 | } 32 | 33 | public LoginResponse login(LoginRequest loginRequest) { 34 | Authenticate(loginRequest.getUsername(), loginRequest.getPassword()); 35 | UserDetails userDetails = userDetailsService.loadUserByUsername(loginRequest.getUsername()); 36 | String token = jwtHelper.generateToken(userDetails); 37 | LoginResponse loginResponse = new LoginResponse(token); 38 | return loginResponse; 39 | } 40 | 41 | public void Authenticate(String username, String password) { 42 | UsernamePasswordAuthenticationToken authenticateToken = new UsernamePasswordAuthenticationToken(username, password); 43 | try { 44 | authenticationManager.authenticate(authenticateToken); 45 | } catch (BadCredentialsException e) { 46 | throw new BadCredentialsException("Invalid Username or Password"); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/build-test-lint-format.yml: -------------------------------------------------------------------------------- 1 | name: Automated Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | pull_request: 8 | branches: 9 | - 'main' 10 | 11 | jobs: 12 | build-and-test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up JDK 17 19 | uses: actions/setup-java@v3 20 | with: 21 | java-version: '17' 22 | distribution: 'temurin' 23 | 24 | # - name: Cache Maven dependencies 25 | # uses: actions/cache@v3 26 | # with: 27 | # path: ~/.m2/repository 28 | # key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} 29 | # restore-keys: | 30 | # ${{ runner.os }}-maven- 31 | 32 | - name: Install dependencies 33 | run: mvn clean install -DskipTests 34 | 35 | - name: Run Tests 36 | run: mvn test 37 | env: 38 | ENV: production 39 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 40 | DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }} 41 | DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} 42 | DATABASE_DRIVER_CLASS_NAME: ${{ secrets.DATABASE_DRIVER_CLASS_NAME }} 43 | 44 | MAIL_SERVICE_HOST: ${{ secrets.MAIL_SERVICE_HOST }} 45 | MAIL_SERVICE_PORT: ${{ secrets.MAIL_SERVICE_PORT }} 46 | MAIL_SERVICE_USERNAME: ${{ secrets.MAIL_SERVICE_USERNAME }} 47 | MAIL_SERVICE_PASSWORD: ${{ secrets.MAIL_SERVICE_PASSWORD }} 48 | MAIL_SERVICE_SMTP: ${{ secrets.MAIL_SERVICE_SMTP }} 49 | MAIL_SERVICE_STARTTLS: ${{ secrets.MAIL_SERVICE_STARTTLS }} 50 | MAIL_SERVICE_DOMAIN_NAME: ${{ secrets.MAIL_SERVICE_DOMAIN_NAME }} 51 | 52 | # lint-and-format: 53 | # runs-on: ubuntu-latest 54 | # needs: build-and-test 55 | # steps: 56 | # - name: Checkout code 57 | # uses: actions/checkout@v3 58 | # 59 | # - name: Set up JDK 17 60 | # uses: actions/setup-java@v3 61 | # with: 62 | # java-version: '17' 63 | # distribution: 'temurin' 64 | # 65 | # - name: Run Checkstyle 66 | # run: mvn checkstyle:check 67 | # 68 | # - name: Run PMD 69 | # run: mvn pmd:check 70 | # 71 | # - name: Run SpotBugs 72 | # run: mvn spotbugs:check 73 | 74 | # - name: Verify code formatting with Spotless (excluding Javadocs) 75 | # run: mvn spotless:check -Dspotless.apply.skip 76 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/exception/InvalidPasswordException.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.exception; 2 | 3 | import java.io.Serial; 4 | 5 | /** 6 | * Custom exception class to handle scenarios where an invalid password is provided 7 | * in the Library Management System. This exception is thrown when a password update 8 | * operation fails due to invalid password criteria. 9 | */ 10 | public class InvalidPasswordException extends RuntimeException { 11 | 12 | /** 13 | * The {@code serialVersionUID} is a unique identifier for each version of a serializable class. 14 | * It is used during the deserialization process to verify that the sender and receiver of a 15 | * serialized object have loaded classes for that object that are compatible with each other. 16 | *

17 | * The {@code serialVersionUID} field is important for ensuring that a serialized class 18 | * (especially when transmitted over a network or saved to disk) can be successfully deserialized, 19 | * even if the class definition changes in later versions. If the {@code serialVersionUID} does not 20 | * match during deserialization, an {@code InvalidClassException} is thrown. 21 | *

22 | * This field is optional, but it is good practice to explicitly declare it to prevent 23 | * automatic generation, which could lead to compatibility issues when the class structure changes. 24 | *

25 | * The {@code @Serial} annotation is used here to indicate that this field is related to 26 | * serialization. This annotation is available starting from Java 14 and helps improve clarity 27 | * regarding the purpose of this field. 28 | */ 29 | @Serial 30 | private static final long serialVersionUID = 1L; 31 | 32 | /** 33 | * Constructs a new {@code InvalidPasswordException} with the specified detail message. 34 | * 35 | * @param message the detail message explaining the reason for the exception 36 | */ 37 | public InvalidPasswordException(String message) { 38 | super(message); 39 | } 40 | 41 | /** 42 | * Constructs a new {@code InvalidPasswordException} with the specified detail message and cause. 43 | * 44 | * @param message the detail message explaining the reason for the exception 45 | * @param cause the cause of the exception (which is saved for later retrieval by the {@link #getCause()} method) 46 | */ 47 | public InvalidPasswordException(String message, Throwable cause) { 48 | super(message, cause); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/exception/ErrorDetails.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.exception; 2 | 3 | import java.util.Date; 4 | 5 | /** 6 | * The compiler automatically generates the following members: 7 | *

    8 | *
  • A private final field for each component of the record.
  • 9 | *
  • A public constructor with parameters for each component.
  • 10 | *
  • Public getter methods for each component (the method name is the same as the component name).
  • 11 | *
  • A toString() method that includes the names and values of the components.
  • 12 | *
  • An equals() method that checks for equality based on the components.
  • 13 | *
  • A hashCode() method that calculates a hash code based on the components.
  • 14 | *
15 | *

16 | * For the ErrorDetails record, the compiler will automatically generate the following members: 17 | *

18 | * Private final fields: 19 | *

    20 | *
  • private final Date timestamp;
  • 21 | *
  • private final String message;
  • 22 | *
  • private final String details;
  • 23 | *
24 | *

25 | *

26 | * Public constructor: 27 | *

public ErrorDetails(Date timestamp, String message, String details) { ... }
28 | *

29 | *

30 | * Public getter methods: 31 | *

    32 | *
  • public Date timestamp() { return timestamp; }
  • 33 | *
  • public String message() { return message; }
  • 34 | *
  • public String details() { return details; }
  • 35 | *
36 | *

37 | *

38 | * toString() method: 39 | *

public String toString() { return "ErrorDetails[timestamp=" + timestamp + ", message=" + message + ", details=" + details + "]"; }
40 | *

41 | *

42 | * equals() method: 43 | *

@Override
44 |  *     public boolean equals(Object obj) {
45 |  *         if (this == obj) return true;
46 |  *         if (obj == null || getClass() != obj.getClass()) return false;
47 |  *         ErrorDetails other = (ErrorDetails) obj;
48 |  *         return timestamp.equals(other.timestamp) && message.equals(other.message) && details.equals(other.details);
49 |  *     }
50 | *

51 | *

52 | * hashCode() method: 53 | *

@Override
54 |  *     public int hashCode() {
55 |  *         return java.util.Objects.hash(timestamp, message, details);
56 |  *     }
57 | *

58 | */ 59 | public record ErrorDetails(Date timestamp, String message, String details) { 60 | } -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/exception/ResourceNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.exception; 2 | 3 | import java.io.Serial; 4 | 5 | /** 6 | * Custom exception class to handle scenarios where a requested resource 7 | * is not found in the Library Management System. 8 | * This exception is thrown when an operation fails to locate a resource such as 9 | * a book, member, or borrowing record. 10 | */ 11 | public class ResourceNotFoundException extends RuntimeException { 12 | 13 | /** 14 | * The {@code serialVersionUID} is a unique identifier for each version of a serializable class. 15 | * It is used during the deserialization process to verify that the sender and receiver of a 16 | * serialized object have loaded classes for that object that are compatible with each other. 17 | *

18 | * The {@code serialVersionUID} field is important for ensuring that a serialized class 19 | * (especially when transmitted over a network or saved to disk) can be successfully deserialized, 20 | * even if the class definition changes in later versions. If the {@code serialVersionUID} does not 21 | * match during deserialization, an {@code InvalidClassException} is thrown. 22 | *

23 | * This field is optional, but it is good practice to explicitly declare it to prevent 24 | * automatic generation, which could lead to compatibility issues when the class structure changes. 25 | *

26 | * The {@code @Serial} annotation is used here to indicate that this field is related to 27 | * serialization. This annotation is available starting from Java 14 and helps improve clarity 28 | * regarding the purpose of this field. 29 | */ 30 | @Serial 31 | private static final long serialVersionUID = 1L; 32 | 33 | /** 34 | * Constructs a new {@code ResourceNotFoundException} with the specified detail message. 35 | * 36 | * @param message the detail message explaining the reason for the exception 37 | */ 38 | public ResourceNotFoundException(String message) { 39 | super(message); 40 | } 41 | 42 | /** 43 | * Constructs a new {@code ResourceNotFoundException} with the specified detail message and cause. 44 | * 45 | * @param message the detail message explaining the reason for the exception 46 | * @param cause the cause of the exception (which is saved for later retrieval by the {@link #getCause()} method) 47 | */ 48 | public ResourceNotFoundException(String message, Throwable cause) { 49 | super(message, cause); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/exception/InvalidSortFieldException.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.exception; 2 | 3 | import java.io.Serial; 4 | 5 | /** 6 | * Custom exception class to handle scenarios where an invalid sort field 7 | * is provided for API requests in the Library Management System. 8 | * This exception is thrown when a sorting operation is attempted 9 | * using a field that does not exist or is not supported. 10 | */ 11 | public class InvalidSortFieldException extends RuntimeException { 12 | 13 | /** 14 | * The {@code serialVersionUID} is a unique identifier for each version of a serializable class. 15 | * It is used during the deserialization process to verify that the sender and receiver of a 16 | * serialized object have loaded classes for that object that are compatible with each other. 17 | *

18 | * The {@code serialVersionUID} field is important for ensuring that a serialized class 19 | * (especially when transmitted over a network or saved to disk) can be successfully deserialized, 20 | * even if the class definition changes in later versions. If the {@code serialVersionUID} does not 21 | * match during deserialization, an {@code InvalidClassException} is thrown. 22 | *

23 | * This field is optional, but it is good practice to explicitly declare it to prevent 24 | * automatic generation, which could lead to compatibility issues when the class structure changes. 25 | *

26 | * The {@code @Serial} annotation is used here to indicate that this field is related to 27 | * serialization. This annotation is available starting from Java 14 and helps improve clarity 28 | * regarding the purpose of this field. 29 | */ 30 | @Serial 31 | private static final long serialVersionUID = 1L; 32 | 33 | /** 34 | * Constructs a new {@code InvalidSortFieldException} with the specified detail message. 35 | * 36 | * @param message the detail message explaining the reason for the exception 37 | */ 38 | public InvalidSortFieldException(String message) { 39 | super(message); 40 | } 41 | 42 | /** 43 | * Constructs a new {@code InvalidSortFieldException} with the specified detail message and cause. 44 | * 45 | * @param message the detail message explaining the reason for the exception 46 | * @param cause the cause of the exception (which is saved for later retrieval by the {@link #getCause()} method) 47 | */ 48 | public InvalidSortFieldException(String message, Throwable cause) { 49 | super(message, cause); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Report a Bug 🐞 2 | description: Help us improve by reporting any issues you encounter. 3 | title: "[BUG] Brief description" 4 | labels: ["status: awaiting triage"] 5 | #assignees: ' ' 6 | body: 7 | - type: checkboxes 8 | id: duplicates 9 | attributes: 10 | label: Is this a new issue? 11 | description: Please confirm you've checked that this issue hasn't been reported before. 12 | options: 13 | - label: I have searched "open" and "closed" issues, and this is not a duplicate. 14 | required: true 15 | 16 | - type: textarea 17 | id: description 18 | attributes: 19 | label: Bug Description 20 | description: Please provide a clear description of the issue. You can explain it in your own way if preferred. 21 | placeholder: | 22 | Please describe the bug in detail. For example: 23 | - What happened? 24 | - Where did it happen? 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | id: steps-to-reproduce 30 | attributes: 31 | label: Steps to Reproduce 32 | description: Outline the steps to reproduce the issue. If unsure, just provide any relevant details. 33 | placeholder: | 34 | If you know the steps, you can follow this format: 35 | 36 | 1. Go to [page or feature] 37 | 2. [Describe action taken] 38 | 3. [Describe what went wrong] 39 | 40 | If unsure, you can explain what you were doing or what you expected to happen. 41 | validations: 42 | required: false 43 | 44 | - type: textarea 45 | id: screenshots 46 | attributes: 47 | label: Screenshots 48 | description: Attach any screenshots that might help clarify the issue (if applicable). 49 | placeholder: Upload or drag and drop images here. 50 | validations: 51 | required: false 52 | 53 | - type: dropdown 54 | id: assignee 55 | attributes: 56 | label: Would you like to work on this issue? 57 | options: 58 | - "Yes" 59 | - "No" 60 | validations: 61 | required: true 62 | 63 | - type: textarea 64 | id: implementation-plan 65 | attributes: 66 | label: Implementation Plan 67 | description: If you selected "Yes" above, please describe how you would approach fixing this issue (Optional). 68 | placeholder: Provide a brief plan or any initial thoughts on fixing the bug. 69 | validations: 70 | required: false 71 | 72 | - type: markdown 73 | attributes: 74 | value: | 75 | Thank you for reporting this bug! Please ensure you've filled out all the required sections to help us address the issue as efficiently as possible. -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/notification/Notifications.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.notification; 2 | 3 | import com.libraryman_api.member.Members; 4 | import jakarta.persistence.*; 5 | 6 | import java.sql.Timestamp; 7 | 8 | @Entity 9 | public class Notifications { 10 | 11 | @Id 12 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "notification_id_generator") 13 | @SequenceGenerator(name = "notification_id_generator", sequenceName = "notification_id_sequence", allocationSize = 1) 14 | @Column(name = "notification_id") 15 | private int notificationId; 16 | 17 | @ManyToOne 18 | @JoinColumn(name = "member_id", nullable = false) 19 | private Members member; 20 | 21 | @Column(nullable = false, length = 500) 22 | private String message; 23 | 24 | @Enumerated(EnumType.STRING) 25 | @Column(name = "notification_type", nullable = false) 26 | private NotificationType notificationType; 27 | 28 | @Column(name = "sent_date", nullable = false) 29 | private Timestamp sentDate; 30 | 31 | @Enumerated(EnumType.STRING) 32 | private NotificationStatus notificationStatus; 33 | 34 | 35 | public Notifications() { 36 | } 37 | 38 | public Notifications(Members member, String message, NotificationType notificationType, Timestamp sentDate, NotificationStatus notificationStatus) { 39 | this.member = member; 40 | this.message = message; 41 | this.notificationType = notificationType; 42 | this.sentDate = sentDate; 43 | this.notificationStatus = notificationStatus; 44 | } 45 | 46 | public int getNotificationId() { 47 | return notificationId; 48 | } 49 | 50 | public Members getMember() { 51 | return member; 52 | } 53 | 54 | public void setMember(Members member) { 55 | this.member = member; 56 | } 57 | 58 | public String getMessage() { 59 | return message; 60 | } 61 | 62 | public void setMessage(String message) { 63 | this.message = message; 64 | } 65 | 66 | public NotificationType getNotificationType() { 67 | return notificationType; 68 | } 69 | 70 | public void setNotificationType(NotificationType notificationType) { 71 | this.notificationType = notificationType; 72 | } 73 | 74 | public Timestamp getSentDate() { 75 | return sentDate; 76 | } 77 | 78 | public void setSentDate(Timestamp sentDate) { 79 | this.sentDate = sentDate; 80 | } 81 | 82 | public NotificationStatus getNotificationStatus() { 83 | return notificationStatus; 84 | } 85 | 86 | public void setNotificationStatus(NotificationStatus notificationStatus) { 87 | this.notificationStatus = notificationStatus; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/jwt/JwtAuthenticationFilter.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.jwt; 2 | 3 | 4 | import jakarta.servlet.FilterChain; 5 | import jakarta.servlet.ServletException; 6 | import jakarta.servlet.http.HttpServletRequest; 7 | import jakarta.servlet.http.HttpServletResponse; 8 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 9 | import org.springframework.security.core.context.SecurityContextHolder; 10 | import org.springframework.security.core.userdetails.UserDetails; 11 | import org.springframework.security.core.userdetails.UserDetailsService; 12 | import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; 13 | import org.springframework.stereotype.Component; 14 | import org.springframework.web.filter.OncePerRequestFilter; 15 | 16 | import java.io.IOException; 17 | 18 | @Component 19 | public class JwtAuthenticationFilter extends OncePerRequestFilter { 20 | 21 | private final JwtAuthenticationHelper jwtHelper; 22 | 23 | private final UserDetailsService userDetailsService; 24 | 25 | public JwtAuthenticationFilter(JwtAuthenticationHelper jwtHelper, UserDetailsService userDetailsService) { 26 | this.jwtHelper = jwtHelper; 27 | this.userDetailsService = userDetailsService; 28 | } 29 | 30 | @Override 31 | protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) 32 | throws ServletException, IOException { 33 | 34 | String requestHeader = request.getHeader("Authorization"); 35 | String username = null; 36 | String token = null; 37 | if (requestHeader != null && requestHeader.startsWith("Bearer ")) { 38 | token = requestHeader.substring(7); 39 | username = jwtHelper.getUsernameFromToken(token); 40 | if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { 41 | UserDetails userDetails = userDetailsService.loadUserByUsername(username); 42 | if (!(jwtHelper.isTokenExpired(token))) { 43 | UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); 44 | usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); 45 | SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); 46 | 47 | } else { 48 | System.out.println("Token is expired or user details not found."); 49 | } 50 | } 51 | 52 | } 53 | filterChain.doFilter(request, response); 54 | } 55 | 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/test/java/com/libraryman_api/security/services/LoginServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.services; 2 | 3 | import com.libraryman_api.TestUtil; 4 | import com.libraryman_api.member.MemberRepository; 5 | import com.libraryman_api.member.Members; 6 | import com.libraryman_api.security.jwt.JwtAuthenticationHelper; 7 | import com.libraryman_api.security.model.LoginRequest; 8 | import com.libraryman_api.security.model.LoginResponse; 9 | import org.junit.jupiter.api.Nested; 10 | import org.junit.jupiter.api.Test; 11 | import org.junit.jupiter.api.extension.ExtendWith; 12 | import org.mockito.InjectMocks; 13 | import org.mockito.Mock; 14 | import org.mockito.junit.jupiter.MockitoExtension; 15 | import org.springframework.security.authentication.AuthenticationManager; 16 | import org.springframework.security.authentication.BadCredentialsException; 17 | import org.springframework.security.core.userdetails.UserDetailsService; 18 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 19 | 20 | import static org.junit.jupiter.api.Assertions.assertEquals; 21 | import static org.junit.jupiter.api.Assertions.assertThrows; 22 | import static org.mockito.Mockito.any; 23 | import static org.mockito.Mockito.when; 24 | 25 | /** 26 | * Tests the {@link LoginService}. 27 | */ 28 | @ExtendWith(MockitoExtension.class) 29 | class LoginServiceTest { 30 | @Mock 31 | private AuthenticationManager authenticationManager; 32 | @Mock 33 | private UserDetailsService userDetailsService; 34 | @Mock 35 | private JwtAuthenticationHelper jwtHelper; 36 | @Mock 37 | private MemberRepository memberRepository; 38 | @InjectMocks 39 | private LoginService loginService; 40 | 41 | @Nested 42 | class Login { 43 | @Test 44 | void success() { 45 | LoginRequest loginRequest = TestUtil.getLoginRequest(); 46 | String token = "jwtToken"; 47 | Members members = TestUtil.getMembers(); 48 | 49 | when(userDetailsService.loadUserByUsername(any())).thenReturn(members); 50 | when(jwtHelper.generateToken(members)).thenReturn(token); 51 | 52 | LoginResponse response = loginService.login(loginRequest); 53 | 54 | assertEquals(token, response.getToken()); 55 | } 56 | 57 | @Test 58 | void badCredentials() { 59 | when(authenticationManager.authenticate(any())).thenThrow(BadCredentialsException.class); 60 | 61 | assertThrows(BadCredentialsException.class, () -> loginService.login(TestUtil.getLoginRequest())); 62 | } 63 | 64 | @Test 65 | void userNotFound() { 66 | when(userDetailsService.loadUserByUsername(any())).thenThrow(UsernameNotFoundException.class); 67 | 68 | assertThrows(UsernameNotFoundException.class, () -> loginService.login(TestUtil.getLoginRequest())); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/test/java/com/libraryman_api/analytics/AnalyticsServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.analytics; 2 | 3 | import com.libraryman_api.book.BookRepository; 4 | import com.libraryman_api.borrowing.BorrowingRepository; 5 | import com.libraryman_api.member.MemberRepository; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.InjectMocks; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | 12 | import java.time.LocalDate; 13 | import java.util.List; 14 | import java.util.Map; 15 | 16 | import static org.junit.jupiter.api.Assertions.assertEquals; 17 | import static org.mockito.ArgumentMatchers.any; 18 | import static org.mockito.Mockito.when; 19 | 20 | /** 21 | * Tests the {@link AnalyticsService} class. 22 | */ 23 | @ExtendWith(MockitoExtension.class) 24 | class AnalyticsServiceTest { 25 | @Mock 26 | private BookRepository bookRepository; 27 | @Mock 28 | private BorrowingRepository borrowingRepository; 29 | @Mock 30 | private MemberRepository memberRepository; 31 | @InjectMocks 32 | private AnalyticsService analyticsService; 33 | 34 | @Test 35 | void getLibraryOverview() { 36 | when(bookRepository.count()).thenReturn(100L); 37 | when(borrowingRepository.count()).thenReturn(50L); 38 | when(memberRepository.count()).thenReturn(25L); 39 | 40 | Map overview = analyticsService.getLibraryOverview(); 41 | 42 | assertEquals(100L, overview.get("totalBooks")); 43 | assertEquals(25L, overview.get("totalMembers")); 44 | assertEquals(50L, overview.get("totalBorrowings")); 45 | } 46 | 47 | @Test 48 | void getPopularBooks() { 49 | List> expectedList = List.of(Map.of("title", "Book A", "borrowCount", 10), Map.of("title", "Book B", "borrowCount", 8)); 50 | when(borrowingRepository.findMostBorrowedBooks(2)).thenReturn(expectedList); 51 | 52 | List> result = analyticsService.getPopularBooks(2); 53 | 54 | assertEquals(expectedList, result); 55 | } 56 | 57 | @Test 58 | void getBorrowingTrends() { 59 | Map trends = Map.of("2025-06-01", 5L, "2025-06-02", 3L); 60 | when(borrowingRepository.getBorrowingTrendsBetweenDates(any(), any())).thenReturn(trends); 61 | 62 | Map result = analyticsService.getBorrowingTrends(LocalDate.now().minusDays(1), LocalDate.now()); 63 | 64 | assertEquals(trends, result); 65 | } 66 | 67 | @Test 68 | void getMemberActivityReport() { 69 | List> report = List.of(Map.of("memberId", 1, "borrowCount", 10), Map.of("memberId", 2, "borrowCount", 5)); 70 | when(memberRepository.getMemberActivityReport()).thenReturn(report); 71 | 72 | List> result = analyticsService.getMemberActivityReport(); 73 | 74 | assertEquals(report, result); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 💡 2 | description: Have a new idea or feature? Let us know... 3 | title: "[FEATURE] " 4 | labels: ["status: awaiting triage"] 5 | 6 | body: 7 | - type: checkboxes 8 | id: duplicates 9 | attributes: 10 | label: Is this feature already requested? 11 | description: Ensure this feature hasn't been suggested before. 12 | options: 13 | - label: I have checked "open" and "closed" issues, and this is not a duplicate. 14 | required: true 15 | 16 | - type: textarea 17 | id: problem 18 | attributes: 19 | label: Problem or Missing Functionality 20 | description: Briefly describe the problem or the missing functionality that this feature would address. 21 | placeholder: "For ex: I often encounter [specific problem] when using [current feature or lack thereof]. Implementing this feature would improve [specific aspect or workflow] by [explain how it will help]." 22 | validations: 23 | required: false 24 | 25 | - type: textarea 26 | id: solution 27 | attributes: 28 | label: Feature Description 29 | description: Describe the feature you're suggesting and how it would solve the problem. Include any relevant details or references. 30 | placeholder: "For ex: I suggest adding [describe the feature] that will [explain its impact]. This feature could be similar to [mention any known implementations or inspirations], which helps by [describe how it benefits the users]." 31 | validations: 32 | required: true 33 | 34 | - type: textarea 35 | id: screenshots 36 | attributes: 37 | label: Screenshots 38 | description: Attach any screenshots that might help illustrate the feature or its need (Optional). 39 | placeholder: "If applicable, drag and drop images here or click to upload. Screenshots can help clarify your suggestion by showing the current issue or how the feature might look." 40 | validations: 41 | required: false 42 | 43 | - type: dropdown 44 | id: work_on_issue 45 | attributes: 46 | label: Would you like to work on this feature? 47 | options: 48 | - "Yes" 49 | - "No" 50 | validations: 51 | required: true 52 | 53 | - type: textarea 54 | id: implementation_plan 55 | attributes: 56 | label: Implementation Plan 57 | description: If you selected "Yes" above, briefly describe how you plan to implement this feature (Optional). 58 | placeholder: "For example: I plan to start by [outline your steps], using [mention any tools, libraries, or frameworks]. This will help ensure the feature is developed efficiently and meets the intended purpose." 59 | validations: 60 | required: false 61 | 62 | - type: markdown 63 | attributes: 64 | value: | 65 | Thank you for suggesting a new feature! Please ensure you've filled out all the required sections to help us evaluate your suggestion effectively. 66 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/fine/Fine.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.fine; 2 | 3 | import jakarta.persistence.*; 4 | 5 | import java.math.BigDecimal; 6 | 7 | /** 8 | * Represents a fine in the Library Management System. 9 | * Each fine has an amount, a payment status, and a unique identifier. 10 | */ 11 | @Entity 12 | public class Fine { 13 | 14 | @Id 15 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "fine_id_generator") 16 | @SequenceGenerator(name = "fine_id_generator", sequenceName = "fine_id_sequence", allocationSize = 1) 17 | @Column(updatable = false, nullable = false) 18 | private int fineId; 19 | 20 | /** 21 | * The amount of the fine with a precision of 10 and a scale of 2. 22 | * Precision = 10 means the total number of digits (including decimal places) is 10. 23 | * Scale = 2 means the number of decimal places is 2. 24 | */ 25 | @Column(nullable = false, precision = 10, scale = 2) 26 | private BigDecimal amount; 27 | 28 | /** 29 | * Indicates whether the fine has been paid. 30 | */ 31 | @Column(nullable = false) 32 | private boolean paid = false; 33 | 34 | // Default constructor for JPA 35 | public Fine() { 36 | } 37 | 38 | // Constructor with fields 39 | public Fine(BigDecimal amount, boolean paid) { 40 | this.amount = amount; 41 | this.paid = paid; 42 | } 43 | 44 | /** 45 | * Gets the unique ID of the fine. 46 | * 47 | * @return the unique fine ID 48 | */ 49 | public int getFineId() { 50 | return fineId; 51 | } 52 | 53 | /** 54 | * Gets the amount of the fine. 55 | * 56 | * @return the amount of the fine 57 | */ 58 | public BigDecimal getAmount() { 59 | return amount; 60 | } 61 | 62 | /** 63 | * Sets the amount of the fine. 64 | * 65 | * @param amount the amount to set 66 | */ 67 | public void setAmount(BigDecimal amount) { 68 | this.amount = amount; 69 | } 70 | 71 | /** 72 | * Checks if the fine has been paid. 73 | * 74 | * @return true if the fine is paid, false otherwise 75 | */ 76 | public boolean isPaid() { 77 | return paid; 78 | } 79 | 80 | /** 81 | * Sets the payment status of the fine. 82 | * 83 | * @param paid true if the fine is paid, false otherwise 84 | */ 85 | public void setPaid(boolean paid) { 86 | this.paid = paid; 87 | } 88 | 89 | /** 90 | * Provides a string representation of the Fine object. 91 | * 92 | * @return a string containing the fine details 93 | */ 94 | @Override 95 | public String toString() { 96 | return "Fine{" + 97 | "fineId=" + fineId + 98 | ", amount=" + amount + 99 | ", paid=" + paid + 100 | '}'; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/borrowing/Borrowings.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.borrowing; 2 | 3 | import com.libraryman_api.book.Book; 4 | import com.libraryman_api.fine.Fine; 5 | import com.libraryman_api.member.Members; 6 | import jakarta.persistence.*; 7 | 8 | import java.util.Date; 9 | 10 | @Entity 11 | public class Borrowings { 12 | @Id 13 | @GeneratedValue(strategy = GenerationType.SEQUENCE, 14 | generator = "borrowing_id_generator") 15 | @SequenceGenerator(name = "borrowing_id_generator", 16 | sequenceName = "borrowing_id_sequence", 17 | allocationSize = 1) 18 | @Column(name = "borrowing_id") 19 | private int borrowingId; 20 | 21 | @ManyToOne 22 | @JoinColumn(name = "book_id", nullable = false) 23 | private Book book; 24 | 25 | @OneToOne 26 | @JoinColumn(name = "fine_id") 27 | private Fine fine; 28 | 29 | @ManyToOne 30 | @JoinColumn(name = "member_id", nullable = false) 31 | private Members member; 32 | 33 | @Column(name = "borrow_date", nullable = false) 34 | private Date borrowDate; 35 | 36 | @Column(name = "due_date", nullable = false) 37 | private Date dueDate; 38 | 39 | @Column(name = "return_date") 40 | private Date returnDate; 41 | 42 | 43 | public Borrowings() { 44 | } 45 | 46 | public Borrowings(Book book, Members member, Date borrowDate, Date dueDate, Date returnDate) { 47 | this.book = book; 48 | this.member = member; 49 | this.borrowDate = borrowDate; 50 | this.dueDate = dueDate; 51 | this.returnDate = returnDate; 52 | } 53 | 54 | public Fine getFine() { 55 | return fine; 56 | } 57 | 58 | public void setFine(Fine fine) { 59 | this.fine = fine; 60 | } 61 | 62 | public int getBorrowingId() { 63 | return borrowingId; 64 | } 65 | 66 | public void setBorrowingId(int borrowingId) { 67 | this.borrowingId = borrowingId; 68 | } 69 | 70 | public Book getBook() { 71 | return book; 72 | } 73 | 74 | public void setBook(Book book) { 75 | this.book = book; 76 | } 77 | 78 | public Members getMember() { 79 | return member; 80 | } 81 | 82 | public void setMember(Members member) { 83 | this.member = member; 84 | } 85 | 86 | public Date getBorrowDate() { 87 | return borrowDate; 88 | } 89 | 90 | public void setBorrowDate(Date borrowDate) { 91 | this.borrowDate = borrowDate; 92 | } 93 | 94 | public Date getDueDate() { 95 | return dueDate; 96 | } 97 | 98 | public void setDueDate(Date dueDate) { 99 | this.dueDate = dueDate; 100 | } 101 | 102 | public Date getReturnDate() { 103 | return returnDate; 104 | } 105 | 106 | public void setReturnDate(Date returnDate) { 107 | this.returnDate = returnDate; 108 | } 109 | } -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/borrowing/BorrowingsDto.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.borrowing; 2 | 3 | import com.libraryman_api.book.BookDto; 4 | import com.libraryman_api.fine.Fine; 5 | import com.libraryman_api.member.dto.MembersDto; 6 | import jakarta.validation.constraints.NotNull; 7 | 8 | import java.util.Date; 9 | 10 | public class BorrowingsDto { 11 | 12 | private int borrowingId; 13 | @NotNull(message = "Book is required") 14 | private BookDto book; 15 | private Fine fine; 16 | @NotNull(message = "Member is required") 17 | private MembersDto member; 18 | 19 | private Date borrowDate; 20 | private Date dueDate; 21 | private Date returnDate; 22 | 23 | 24 | public BorrowingsDto(int borrowingId, BookDto book, Fine fine, MembersDto member, Date borrowDate, Date dueDate, Date returnDate) { 25 | this.borrowingId = borrowingId; 26 | this.book = book; 27 | this.fine = fine; 28 | this.member = member; 29 | this.borrowDate = borrowDate; 30 | this.dueDate = dueDate; 31 | this.returnDate = returnDate; 32 | } 33 | 34 | public BorrowingsDto() { 35 | } 36 | 37 | public int getBorrowingId() { 38 | return borrowingId; 39 | } 40 | 41 | public void setBorrowingId(int borrowingId) { 42 | this.borrowingId = borrowingId; 43 | } 44 | 45 | public BookDto getBook() { 46 | return book; 47 | } 48 | 49 | public void setBook(BookDto book) { 50 | this.book = book; 51 | } 52 | 53 | public Fine getFine() { 54 | return fine; 55 | } 56 | 57 | public void setFine(Fine fine) { 58 | this.fine = fine; 59 | } 60 | 61 | public MembersDto getMember() { 62 | return member; 63 | } 64 | 65 | public void setMember(MembersDto member) { 66 | this.member = member; 67 | } 68 | 69 | public Date getBorrowDate() { 70 | return borrowDate; 71 | } 72 | 73 | public void setBorrowDate(Date borrowDate) { 74 | this.borrowDate = borrowDate; 75 | } 76 | 77 | public Date getDueDate() { 78 | return dueDate; 79 | } 80 | 81 | public void setDueDate(Date dueDate) { 82 | this.dueDate = dueDate; 83 | } 84 | 85 | public Date getReturnDate() { 86 | return returnDate; 87 | } 88 | 89 | public void setReturnDate(Date returnDate) { 90 | this.returnDate = returnDate; 91 | } 92 | 93 | @Override 94 | public String toString() { 95 | return "BorrowingsDto{" + 96 | "borrowingId=" + borrowingId + 97 | ", book=" + book + 98 | ", fine=" + fine + 99 | ", member=" + member + 100 | ", borrowDate=" + borrowDate + 101 | ", dueDate=" + dueDate + 102 | ", returnDate=" + returnDate + 103 | '}'; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/newsletter/NewsletterController.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.newsletter; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.http.ResponseEntity; 5 | import org.springframework.web.bind.annotation.*; 6 | 7 | @RestController 8 | @RequestMapping("/api/newsletter") 9 | public class NewsletterController { 10 | 11 | private final NewsletterService newsletterService; 12 | 13 | public NewsletterController(NewsletterService newsletterService) { 14 | this.newsletterService = newsletterService; 15 | } 16 | 17 | // Subscribe Endpoint 18 | @PostMapping("/subscribe") 19 | public ResponseEntity subscribe(@RequestParam String email) { 20 | try { 21 | String result = newsletterService.subscribe(email); 22 | 23 | return switch (result) { 24 | case "Invalid email format." -> 25 | ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result); // 400 Bad Request 26 | 27 | case "Email is already subscribed." -> 28 | ResponseEntity.status(HttpStatus.CONFLICT).body(result); // 409 Conflict 29 | 30 | case "You have successfully subscribed!" -> 31 | ResponseEntity.status(HttpStatus.CREATED).body(result); // 201 Created 32 | 33 | case "You have successfully re-subscribed!" -> 34 | ResponseEntity.status(HttpStatus.OK).body(result); // 200 OK 35 | 36 | default -> ResponseEntity.status(HttpStatus.OK).body(result); // Default 200 OK 37 | }; 38 | } catch (Exception e) { 39 | // Handle unexpected errors 40 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) 41 | .body("An error occurred while processing your subscription."); 42 | } 43 | } 44 | 45 | // Unsubscribe Endpoint 46 | @GetMapping("/unsubscribe") 47 | public ResponseEntity unsubscribe(@RequestParam String token) { 48 | try { 49 | String result = newsletterService.unsubscribe(token); 50 | 51 | return switch (result) { 52 | case "Invalid or expired token." -> 53 | ResponseEntity.status(HttpStatus.NOT_FOUND).body(result); // 404 Not Found 54 | 55 | case "You are already unsubscribed." -> 56 | ResponseEntity.status(HttpStatus.CONFLICT).body(result); // 409 Conflict 57 | 58 | case "You have successfully unsubscribed!" -> 59 | ResponseEntity.status(HttpStatus.OK).body(result); // 200 OK 60 | 61 | default -> ResponseEntity.status(HttpStatus.OK).body(result); // Default 200 OK 62 | }; 63 | } catch (Exception e) { 64 | // Handle unexpected errors 65 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) 66 | .body("An error occurred while processing your unsubscription."); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/member/Members.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.member; 2 | 3 | import jakarta.persistence.*; 4 | import org.springframework.security.core.GrantedAuthority; 5 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 6 | import org.springframework.security.core.userdetails.UserDetails; 7 | 8 | import java.util.Collection; 9 | import java.util.Collections; 10 | import java.util.Date; 11 | 12 | 13 | @Entity 14 | public class Members implements UserDetails { 15 | 16 | @Id 17 | @GeneratedValue(strategy = GenerationType.SEQUENCE, 18 | generator = "member_id_generator") 19 | @SequenceGenerator(name = "member_id_generator", 20 | sequenceName = "member_id_sequence", 21 | allocationSize = 1) 22 | @Column(name = "member_id") 23 | private int memberId; 24 | 25 | @Column(nullable = false) 26 | private String name; 27 | 28 | @Column(name = "username") 29 | private String username; 30 | 31 | @Column(unique = true, nullable = false) 32 | private String email; 33 | 34 | @Column(nullable = false) 35 | private String password; 36 | 37 | @Enumerated(EnumType.STRING) 38 | @Column(nullable = false) 39 | private Role role; 40 | 41 | @Column(name = "membership_date") 42 | private Date membershipDate; 43 | 44 | 45 | public Members() { 46 | } 47 | 48 | public Members(String name, String email, String password, Role role, Date membershipDate) { 49 | this.name = name; 50 | this.email = email; 51 | this.password = password; 52 | this.role = role; 53 | this.membershipDate = membershipDate; 54 | } 55 | 56 | public int getMemberId() { 57 | return memberId; 58 | } 59 | 60 | public void setMemberId(int memberId) { 61 | this.memberId = memberId; 62 | } 63 | 64 | public String getName() { 65 | return name; 66 | } 67 | 68 | public void setName(String name) { 69 | this.name = name; 70 | } 71 | 72 | public String getEmail() { 73 | return email; 74 | } 75 | 76 | public void setEmail(String email) { 77 | this.email = email; 78 | } 79 | 80 | public String getPassword() { 81 | return password; 82 | } 83 | 84 | public void setPassword(String password) { 85 | this.password = password; 86 | } 87 | 88 | public Role getRole() { 89 | return role; 90 | } 91 | 92 | public void setRole(Role role) { 93 | this.role = role; 94 | } 95 | 96 | public Date getMembershipDate() { 97 | return membershipDate; 98 | } 99 | 100 | public void setMembershipDate(Date membershipDate) { 101 | this.membershipDate = membershipDate; 102 | } 103 | 104 | public String getUsername() { 105 | return username; 106 | } 107 | 108 | public void setUsername(String username) { 109 | this.username = username; 110 | } 111 | 112 | @Override 113 | public Collection getAuthorities() { 114 | // TODO Auto-generated method stub 115 | return Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role.name())); 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /src/test/java/com/libraryman_api/security/jwt/JwtAuthenticationHelperTest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.jwt; 2 | 3 | import com.libraryman_api.TestUtil; 4 | import io.jsonwebtoken.*; 5 | import io.jsonwebtoken.security.Keys; 6 | import io.jsonwebtoken.security.SignatureException; 7 | import org.junit.jupiter.api.BeforeEach; 8 | import org.junit.jupiter.api.Nested; 9 | import org.junit.jupiter.api.Test; 10 | import org.junit.jupiter.api.extension.ExtendWith; 11 | import org.mockito.junit.jupiter.MockitoExtension; 12 | import org.springframework.security.core.userdetails.UserDetails; 13 | 14 | import java.util.Date; 15 | 16 | import static org.junit.jupiter.api.Assertions.*; 17 | 18 | /** 19 | * Tests the {@link JwtAuthenticationHelper}. 20 | */ 21 | @ExtendWith(MockitoExtension.class) 22 | class JwtAuthenticationHelperTest { 23 | private JwtAuthenticationHelper jwtAuthenticationHelper; 24 | private String secret; 25 | 26 | @BeforeEach 27 | void setup() { 28 | secret = "aVeryLongSecretStringThatIsAtLeast64BytesLongAndSecureEnoughForHS512"; 29 | jwtAuthenticationHelper = new JwtAuthenticationHelper(secret); 30 | } 31 | 32 | @Nested 33 | class GetUsernameFromToken { 34 | @Test 35 | void success() { 36 | String expectedUsername = "User"; 37 | String token = Jwts.builder().signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256).setSubject(expectedUsername).setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + 100000)).compact(); 38 | 39 | String actualUsername = jwtAuthenticationHelper.getUsernameFromToken(token); 40 | 41 | assertEquals(expectedUsername, actualUsername); 42 | } 43 | 44 | @Test 45 | void expired() { 46 | Date date = new Date(); 47 | String token = Jwts.builder().signWith(Keys.hmacShaKeyFor(secret.getBytes()), SignatureAlgorithm.HS256).setSubject("User").setIssuedAt(date).setExpiration(date).compact(); 48 | 49 | assertThrows(ExpiredJwtException.class, () -> jwtAuthenticationHelper.getUsernameFromToken(token)); 50 | } 51 | 52 | @Test 53 | void malformed() { 54 | assertThrows(MalformedJwtException.class, () -> jwtAuthenticationHelper.getUsernameFromToken("malformed.token")); 55 | } 56 | 57 | @Test 58 | void signatureException() { 59 | String differentSecret = "notTheSameSecretAsTheSecretValueInTheHelper"; 60 | String token = Jwts.builder().signWith(Keys.hmacShaKeyFor(differentSecret.getBytes()), SignatureAlgorithm.HS256).setSubject("User").setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + 100000)).compact(); 61 | 62 | assertThrows(SignatureException.class, () -> jwtAuthenticationHelper.getUsernameFromToken(token)); 63 | } 64 | } 65 | 66 | @Test 67 | void generateToken() { 68 | UserDetails userDetails = TestUtil.getMembers(); 69 | 70 | String token = jwtAuthenticationHelper.generateToken(userDetails); 71 | 72 | Claims claims = jwtAuthenticationHelper.getClaimsFromToken(token); 73 | assertEquals(userDetails.getUsername(), claims.getSubject()); 74 | assertNotNull(claims.getIssuedAt()); 75 | assertNotNull(claims.getExpiration()); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/newsletter/NewsletterService.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.newsletter; 2 | 3 | import com.libraryman_api.email.EmailService; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.util.Optional; 8 | import java.util.regex.Pattern; 9 | 10 | @Service 11 | public class NewsletterService { 12 | 13 | private final NewsletterSubscriberRepository subscriberRepository; 14 | private final EmailService emailService; 15 | 16 | @Autowired 17 | public NewsletterService(NewsletterSubscriberRepository subscriberRepository, EmailService emailService) { 18 | this.subscriberRepository = subscriberRepository; 19 | this.emailService = emailService; 20 | } 21 | 22 | public String subscribe(String email) { 23 | if (!isValidEmail(email)) return "Invalid email format."; 24 | 25 | Optional optionalSubscriber = subscriberRepository.findByEmail(email); 26 | if (optionalSubscriber.isPresent()) { 27 | NewsletterSubscriber subscriber = optionalSubscriber.get(); 28 | if (!subscriber.isActive()) { 29 | subscriber.setActive(true); 30 | subscriber.regenerateToken(); 31 | subscriberRepository.save(subscriber); 32 | sendSubscriptionEmail(email, subscriber.getUnsubscribeToken()); 33 | return "You have successfully re-subscribed!"; 34 | } 35 | return "Email is already subscribed."; 36 | } 37 | 38 | NewsletterSubscriber newSubscriber = new NewsletterSubscriber(email); 39 | subscriberRepository.save(newSubscriber); 40 | sendSubscriptionEmail(email, newSubscriber.getUnsubscribeToken()); 41 | return "You have successfully subscribed!"; 42 | } 43 | 44 | public String unsubscribe(String token) { 45 | Optional optionalSubscriber = subscriberRepository.findByUnsubscribeToken(token); 46 | if (optionalSubscriber.isEmpty()) return "Invalid or expired token."; 47 | 48 | NewsletterSubscriber subscriber = optionalSubscriber.get(); 49 | if (!subscriber.isActive()) return "You are already unsubscribed."; 50 | 51 | subscriber.setActive(false); 52 | subscriberRepository.save(subscriber); 53 | sendUnsubscribeEmail(subscriber.getEmail()); 54 | return "You have successfully unsubscribed!"; 55 | } 56 | 57 | private boolean isValidEmail(String email) { 58 | String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"; 59 | return Pattern.compile(emailRegex).matcher(email).matches(); 60 | } 61 | 62 | private void sendSubscriptionEmail(String email, String token) { 63 | String subject = "Welcome to Our Newsletter!"; 64 | String body = "Thank you for subscribing! " + 65 | "To unsubscribe, click the link:\n" + 66 | "http://localhost:8080/api/newsletter/unsubscribe?token=" + token; 67 | 68 | emailService.sendEmail(email, body, subject); // No need to change this line 69 | } 70 | 71 | private void sendUnsubscribeEmail(String email) { 72 | String subject = "You have been unsubscribed"; 73 | String body = "You have successfully unsubscribed. If this was a mistake, you can re-subscribe."; 74 | 75 | emailService.sendEmail(email, body, subject); // No need to change this line 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/book/Book.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.book; 2 | 3 | import jakarta.persistence.*; 4 | 5 | 6 | @Entity 7 | public class Book { 8 | @Id 9 | 10 | @GeneratedValue(strategy = GenerationType.SEQUENCE, 11 | generator = "book_id_generator") 12 | @SequenceGenerator(name = "book_id_generator", 13 | sequenceName = "book_id_sequence", 14 | allocationSize = 1) 15 | @Column(name = "book_id") 16 | private int bookId; 17 | 18 | @Column(nullable = false) 19 | private String title; 20 | 21 | private String author; 22 | 23 | @Column(unique = true, nullable = false) 24 | private String isbn; 25 | 26 | private String publisher; 27 | 28 | @Column(name = "published_year") 29 | private int publishedYear; 30 | private String genre; 31 | 32 | 33 | @Column(name = "copies_available", nullable = false) 34 | private int copiesAvailable; 35 | 36 | public Book() { 37 | } 38 | 39 | public Book(String title, String author, String isbn, String publisher, int publishedYear, String genre, int copiesAvailable) { 40 | this.title = title; 41 | this.author = author; 42 | this.isbn = isbn; 43 | this.publisher = publisher; 44 | this.publishedYear = publishedYear; 45 | this.genre = genre; 46 | this.copiesAvailable = copiesAvailable; 47 | } 48 | 49 | public int getBookId() { 50 | return bookId; 51 | } 52 | 53 | public void setBookId(int bookId) { 54 | this.bookId = bookId; 55 | } 56 | 57 | public String getTitle() { 58 | return title; 59 | } 60 | 61 | public void setTitle(String title) { 62 | this.title = title; 63 | } 64 | 65 | public String getAuthor() { 66 | return author; 67 | } 68 | 69 | public void setAuthor(String author) { 70 | this.author = author; 71 | } 72 | 73 | public String getIsbn() { 74 | return isbn; 75 | } 76 | 77 | public void setIsbn(String isbn) { 78 | this.isbn = isbn; 79 | } 80 | 81 | public String getPublisher() { 82 | return publisher; 83 | } 84 | 85 | public void setPublisher(String publisher) { 86 | this.publisher = publisher; 87 | } 88 | 89 | public int getPublishedYear() { 90 | return publishedYear; 91 | } 92 | 93 | public void setPublishedYear(int publishedYear) { 94 | this.publishedYear = publishedYear; 95 | } 96 | 97 | public String getGenre() { 98 | return genre; 99 | } 100 | 101 | public void setGenre(String genre) { 102 | this.genre = genre; 103 | } 104 | 105 | public int getCopiesAvailable() { 106 | return copiesAvailable; 107 | } 108 | 109 | public void setCopiesAvailable(int copiesAvailable) { 110 | this.copiesAvailable = copiesAvailable; 111 | } 112 | 113 | @Override 114 | public String toString() { 115 | return "Books{" + 116 | "bookId=" + bookId + 117 | ", title='" + title + '\'' + 118 | ", author='" + author + '\'' + 119 | ", isbn='" + isbn + '\'' + 120 | ", publisher='" + publisher + '\'' + 121 | ", publishedYear=" + publishedYear + 122 | ", genre='" + genre + '\'' + 123 | ", copiesAvailable=" + copiesAvailable + 124 | '}'; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/member/dto/MembersDto.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.member.dto; 2 | 3 | import com.libraryman_api.member.Role; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.Pattern; 6 | import jakarta.validation.constraints.Size; 7 | 8 | import java.util.Date; 9 | 10 | public class MembersDto { 11 | 12 | private int memberId; 13 | @NotBlank(message = "Name is required") 14 | @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters") 15 | private String name; 16 | @NotBlank(message = "Username is required") 17 | @Size(min = 4, max = 50, message = "Username must be between 4 and 50 characters") 18 | private String username; 19 | 20 | @Pattern(regexp = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$", message = "Please enter a valid email address (e.g., user@example.com)") 21 | @NotBlank(message = "Email field cannot be empty. Please provide a valid email address.") 22 | private String email; 23 | 24 | @NotBlank(message = "Password is required") 25 | @Size(min = 8, message = "Password must be at least 8 characters long") 26 | @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[@#$%^&+=]).*$", 27 | message = "Password must contain at least one letter, one number, and one special character") 28 | private String password; 29 | 30 | 31 | private Role role; 32 | 33 | private Date membershipDate; 34 | 35 | public MembersDto(int memberId, String name, String username, String email, String password, Role role, Date membershipDate) { 36 | this.memberId = memberId; 37 | this.name = name; 38 | this.username = username; 39 | this.email = email; 40 | this.password = password; 41 | this.role = role; 42 | this.membershipDate = membershipDate; 43 | } 44 | 45 | public MembersDto() { 46 | } 47 | 48 | public int getMemberId() { 49 | return memberId; 50 | } 51 | 52 | public void setMemberId(int memberId) { 53 | this.memberId = memberId; 54 | } 55 | 56 | public String getName() { 57 | return name; 58 | } 59 | 60 | public void setName(String name) { 61 | this.name = name; 62 | } 63 | 64 | public String getUsername() { 65 | return username; 66 | } 67 | 68 | public void setUsername(String username) { 69 | this.username = username; 70 | } 71 | 72 | public String getEmail() { 73 | return email; 74 | } 75 | 76 | public void setEmail(String email) { 77 | this.email = email; 78 | } 79 | 80 | public String getPassword() { 81 | return password; 82 | } 83 | 84 | public void setPassword(String password) { 85 | this.password = password; 86 | } 87 | 88 | public Role getRole() { 89 | return role; 90 | } 91 | 92 | public void setRole(Role role) { 93 | this.role = role; 94 | } 95 | 96 | public Date getMembershipDate() { 97 | return membershipDate; 98 | } 99 | 100 | public void setMembershipDate(Date membershipDate) { 101 | this.membershipDate = membershipDate; 102 | } 103 | 104 | @Override 105 | public String toString() { 106 | return "MembersDto{" + 107 | "memberId=" + memberId + 108 | ", name='" + name + '\'' + 109 | ", username='" + username + '\'' + 110 | ", email='" + email + '\'' + 111 | ", password='" + password + '\'' + 112 | ", role=" + role + 113 | ", membershipDate=" + membershipDate + 114 | '}'; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /notification-docs/Account Details Updated 🔄.eml: -------------------------------------------------------------------------------- 1 | Date: Thu, 29 Aug 2024 22:51:04 +0530 (IST) 2 | From: hello@libraryman.com 3 | To: ajay@ajaynegi.co 4 | Message-ID: <1312357003.15.1724952064894@localhost> 5 | Subject: =?utf-8?Q?Account_Details_Updated_=F0=9F=94=84?= 6 | MIME-Version: 1.0 7 | Content-Type: text/html;charset=utf-8 8 | Content-Transfer-Encoding: quoted-printable 9 | 10 |

12 | 13 | 14 | 15 | 18 | 19 | 46 | 47 |
20 | =20 21 | 24 | 25 | 42 | 43 |
26 | 28 | 29 | 32 | 38 | 39 |
30 | =20 31 | 34 | Account Details Updated =F0=9F=94=84 37 |
40 | 41 |
44 | =20 45 |
48 | 51 | 52 | 53 | 64 | 65 | 66 |
54 | =20 55 | 57 | 58 | 61 |
60 |
62 | =20 63 |
67 | 68 | 69 | 70 | 73 | 74 | 75 | 76 | 77 | 78 | 89 | 90 | 91 | 92 | 93 | 94 |


80 | =20 81 |

Hi Ajay Negi,

Your account details have been successfull= 84 | y updated as per your request. If you did not authorize this change or if y= 85 | ou notice any discrepancies, please contact us immediately.

Thank yo= 86 | u for keeping your account information up to date.

Best regards,

<= 87 | p>LibraryMan

=20 88 |


95 | 96 |
97 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/services/SignupService.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.services; 2 | 3 | import com.libraryman_api.exception.ResourceNotFoundException; 4 | import com.libraryman_api.member.MemberRepository; 5 | import com.libraryman_api.member.Members; 6 | import com.libraryman_api.member.Role; 7 | import com.libraryman_api.security.config.PasswordEncoder; 8 | import org.springframework.stereotype.Service; 9 | 10 | import java.util.Date; 11 | import java.util.Optional; 12 | 13 | @Service 14 | public class SignupService { 15 | 16 | private final MemberRepository memberRepository; 17 | 18 | private final PasswordEncoder passwordEncoder; 19 | 20 | 21 | public SignupService(MemberRepository memberRepository, PasswordEncoder passwordEncoder) { 22 | this.memberRepository = memberRepository; 23 | this.passwordEncoder = passwordEncoder; 24 | } 25 | 26 | public void signup(Members members) { 27 | Optional memberOptId = memberRepository.findById(members.getMemberId()); 28 | Optional memberOptUsername = memberRepository.findByUsername(members.getUsername()); 29 | if (memberOptId.isPresent()) { 30 | throw new ResourceNotFoundException("User already Exists"); 31 | } 32 | if (memberOptUsername.isPresent()) { 33 | throw new ResourceNotFoundException("User already Exists"); 34 | } 35 | String encoded_password = passwordEncoder.bCryptPasswordEncoder().encode(members.getPassword()); 36 | Members new_members = new Members(); 37 | 38 | // TODO: check for proper username format 39 | new_members.setUsername(members.getUsername()); 40 | 41 | new_members.setName(members.getName()); 42 | 43 | // TODO: check for proper email format 44 | new_members.setEmail(members.getEmail()); 45 | 46 | new_members.setRole(Role.USER); 47 | new_members.setMembershipDate(new Date()); 48 | new_members.setPassword(encoded_password); 49 | memberRepository.save(new_members); 50 | } 51 | 52 | public void signupAdmin(Members members) { 53 | Optional memberOptId = memberRepository.findById(members.getMemberId()); 54 | Optional memberOptUsername = memberRepository.findByUsername(members.getUsername()); 55 | if (memberOptId.isPresent()) { 56 | throw new ResourceNotFoundException("User already Exists"); 57 | } 58 | if (memberOptUsername.isPresent()) { 59 | throw new ResourceNotFoundException("User already Exists"); 60 | } 61 | 62 | String encoded_password = passwordEncoder.bCryptPasswordEncoder().encode(members.getPassword()); 63 | Members new_members = new Members(); 64 | new_members.setEmail(members.getEmail()); 65 | new_members.setName(members.getName()); 66 | new_members.setPassword(encoded_password); 67 | new_members.setRole(Role.ADMIN); 68 | new_members.setMembershipDate(new Date()); 69 | new_members.setUsername(members.getUsername()); 70 | memberRepository.save(new_members); 71 | 72 | } 73 | 74 | public void signupLibrarian(Members members) { 75 | Optional memberOptId = memberRepository.findById(members.getMemberId()); 76 | Optional memberOptUsername = memberRepository.findByUsername(members.getUsername()); 77 | if (memberOptId.isPresent()) { 78 | throw new ResourceNotFoundException("User already Exists"); 79 | } 80 | if (memberOptUsername.isPresent()) { 81 | throw new ResourceNotFoundException("User already Exists"); 82 | } 83 | String encoded_password = passwordEncoder.bCryptPasswordEncoder().encode(members.getPassword()); 84 | Members new_members = new Members(); 85 | new_members.setEmail(members.getEmail()); 86 | new_members.setName(members.getName()); 87 | new_members.setPassword(encoded_password); 88 | new_members.setRole(Role.LIBRARIAN); 89 | new_members.setMembershipDate(new Date()); 90 | new_members.setUsername(members.getUsername()); 91 | memberRepository.save(new_members); 92 | } 93 | } -------------------------------------------------------------------------------- /notification-docs/Book Returned Successfully 📚.eml: -------------------------------------------------------------------------------- 1 | Date: Thu, 29 Aug 2024 22:59:26 +0530 (IST) 2 | From: hello@libraryman.com 3 | To: ajay@ajaynegi.co 4 | Message-ID: <378862217.23.1724952566594@localhost> 5 | Subject: =?utf-8?Q?Book_Returned_Successfully_=F0=9F=93=9A?= 6 | MIME-Version: 1.0 7 | Content-Type: text/html;charset=utf-8 8 | Content-Transfer-Encoding: quoted-printable 9 | 10 |
12 | 13 | 14 | 15 | 18 | 19 | 46 | 47 |
20 | =20 21 | 24 | 25 | 42 | 43 |
26 | 28 | 29 | 32 | 38 | 39 |
30 | =20 31 | 34 | Book Returned Successfully =F0=9F=93=9A 37 |
40 | 41 |
44 | =20 45 |
48 | 51 | 52 | 53 | 64 | 65 | 66 |
54 | =20 55 | 57 | 58 | 61 |
60 |
62 | =20 63 |
67 | 68 | 69 | 70 | 73 | 74 | 75 | 76 | 77 | 78 | 89 | 90 | 91 | 92 | 93 | 94 |


80 | =20 81 |

Hi Ajay Negi,

Thank you for returning 'The Great Gatsby'= 84 | book on Thu Aug 29 22:59:25 IST 2024. We hope you enjoyed the book!
Feel free to explore our collection for your next read. If you have any qu= 86 | estions or need assistance, we=E2=80=99re here to help.

Thank you fo= 87 | r choosing LibraryMan!

Best regards,

LibraryMan

=20 88 |


95 | 96 |
97 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 3.3.3 10 | 11 | 12 | com 13 | libraryman-api 14 | 0.0.1-SNAPSHOT 15 | libraryman-api 16 | Revolutionize book management with LibraryMan! Easily track 17 | stock, borrowers, and due dates, streamlining operations for schools, 18 | companies, and libraries worldwide, ensuring efficient and organized 19 | book lending. 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 17 35 | 36 | 37 | 38 | 39 | org.springframework.boot 40 | spring-boot-starter-data-jpa 41 | 42 | 43 | 44 | org.springframework.boot 45 | spring-boot-starter-web 46 | 47 | 48 | 49 | org.springframework.boot 50 | spring-boot-devtools 51 | runtime 52 | true 53 | 54 | 55 | 56 | io.jsonwebtoken 57 | jjwt-api 58 | 0.11.5 59 | 60 | 61 | 62 | io.jsonwebtoken 63 | jjwt-impl 64 | 0.11.5 65 | runtime 66 | 67 | 68 | 69 | io.jsonwebtoken 70 | jjwt-jackson 71 | 0.12.0 72 | runtime 73 | 74 | 75 | 76 | com.mysql 77 | mysql-connector-j 78 | runtime 79 | 80 | 81 | 82 | org.springframework.boot 83 | spring-boot-starter-test 84 | test 85 | 86 | 87 | 88 | org.springframework.boot 89 | spring-boot-starter-mail 90 | 91 | 92 | 93 | org.springframework.boot 94 | spring-boot-starter-security 95 | 96 | 97 | 98 | org.springframework.security 99 | spring-security-test 100 | test 101 | 102 | 103 | 104 | org.springframework.security 105 | spring-security-oauth2-client 106 | 107 | 108 | 109 | org.springframework.boot 110 | spring-boot-starter-cache 111 | 112 | 113 | 114 | org.springframework.boot 115 | spring-boot-starter-validation 116 | 3.3.12 117 | 118 | 119 | 120 | org.springframework.boot 121 | spring-boot-starter-actuator 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | org.springframework.boot 130 | spring-boot-maven-plugin 131 | 132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/book/BookDto.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.book; 2 | 3 | import jakarta.validation.constraints.*; 4 | 5 | public class BookDto { 6 | 7 | 8 | private int bookId; 9 | @NotBlank(message = "Title is required ") 10 | @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters") 11 | private String title; 12 | 13 | @NotBlank(message = "Author is required") 14 | @Size(min = 1, max = 100, message = "Author name must be between 1 and 100 characters") 15 | private String author; 16 | 17 | @NotBlank(message = "isbn is required") 18 | @Pattern( 19 | regexp = "^(?:\\d{9}[\\dX]|(?:(978|979)-)?\\d{1,5}-\\d{1,7}-\\d{1,7}-\\d{1})$", 20 | message = "Invalid ISBN format. Valid formats include 'XXXXXXXXXX', 'XXXXXXXXXX-X', '978-XXXXXXXXXX', '978-X-XX-XXXXXX-X', etc." 21 | ) 22 | private String isbn; 23 | 24 | 25 | @NotBlank(message = "Publisher is required") 26 | @Size(min = 1, max = 100, message = "Publisher name must be between 1 and 100 characters") 27 | private String publisher; 28 | 29 | @Min(value = 1500, message = "Published year must be at least 1500") 30 | @Max(value = 2100, message = "Published year cannot be more than 2100") 31 | private int publishedYear; 32 | 33 | @NotBlank(message = "Genre is required") 34 | @Size(min = 1, max = 50, message = "Genre must be between 1 and 50 characters") 35 | private String genre; 36 | 37 | @Min(value = 0, message = "Copies available cannot be negative") 38 | private int copiesAvailable; 39 | 40 | public BookDto(int bookId, String title, String author, String isbn, String publisher, int publishedYear, String genre, int copiesAvailable) { 41 | this.bookId = bookId; 42 | this.title = title; 43 | this.author = author; 44 | this.isbn = isbn; 45 | this.publisher = publisher; 46 | this.publishedYear = publishedYear; 47 | this.genre = genre; 48 | this.copiesAvailable = copiesAvailable; 49 | } 50 | 51 | public BookDto() { 52 | } 53 | 54 | public int getBookId() { 55 | return bookId; 56 | } 57 | 58 | public void setBookId(int bookId) { 59 | this.bookId = bookId; 60 | } 61 | 62 | public String getTitle() { 63 | return title; 64 | } 65 | 66 | public void setTitle(String title) { 67 | this.title = title; 68 | } 69 | 70 | public String getAuthor() { 71 | return author; 72 | } 73 | 74 | public void setAuthor(String author) { 75 | this.author = author; 76 | } 77 | 78 | public String getIsbn() { 79 | return isbn; 80 | } 81 | 82 | public void setIsbn(String isbn) { 83 | this.isbn = isbn; 84 | } 85 | 86 | public String getPublisher() { 87 | return publisher; 88 | } 89 | 90 | public void setPublisher(String publisher) { 91 | this.publisher = publisher; 92 | } 93 | 94 | public int getPublishedYear() { 95 | return publishedYear; 96 | } 97 | 98 | public void setPublishedYear(int publishedYear) { 99 | this.publishedYear = publishedYear; 100 | } 101 | 102 | public String getGenre() { 103 | return genre; 104 | } 105 | 106 | public void setGenre(String genre) { 107 | this.genre = genre; 108 | } 109 | 110 | public int getCopiesAvailable() { 111 | return copiesAvailable; 112 | } 113 | 114 | public void setCopiesAvailable(int copiesAvailable) { 115 | this.copiesAvailable = copiesAvailable; 116 | } 117 | 118 | @Override 119 | public String toString() { 120 | return "BookDto{" + 121 | "bookId=" + bookId + 122 | ", title='" + title + '\'' + 123 | ", author='" + author + '\'' + 124 | ", isbn='" + isbn + '\'' + 125 | ", publisher='" + publisher + '\'' + 126 | ", publishedYear=" + publishedYear + 127 | ", genre='" + genre + '\'' + 128 | ", copiesAvailable=" + copiesAvailable + 129 | '}'; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /notification-docs/Payment Received for Fine 💸.eml: -------------------------------------------------------------------------------- 1 | Date: Thu, 29 Aug 2024 22:58:41 +0530 (IST) 2 | From: hello@libraryman.com 3 | To: ajay@ajaynegi.co 4 | Message-ID: <1064214475.22.1724952521643@localhost> 5 | Subject: =?utf-8?Q?Payment_Received_for_Fine_=F0=9F=92=B8?= 6 | MIME-Version: 1.0 7 | Content-Type: text/html;charset=utf-8 8 | Content-Transfer-Encoding: quoted-printable 9 | 10 |
12 | 13 | 14 | 15 | 18 | 19 | 46 | 47 |
20 | =20 21 | 24 | 25 | 42 | 43 |
26 | 28 | 29 | 32 | 38 | 39 |
30 | =20 31 | 34 | Payment Received for Fine =F0=9F=92=B8 37 |
40 | 41 |
44 | =20 45 |
48 | 51 | 52 | 53 | 64 | 65 | 66 |
54 | =20 55 | 57 | 58 | 61 |
60 |
62 | =20 63 |
67 | 68 | 69 | 70 | 73 | 74 | 75 | 76 | 77 | 78 | 91 | 92 | 93 | 94 | 95 | 96 |


80 | =20 81 |

Hi Ajay Negi,

Thank you for your payment. We=E2=80=99ve = 84 | received your payment of =E2=82=B990.00 towards the fine for the overdue re= 85 | turn of 'The Great Gatsby' book. =E2=9C=85

Your account has been upd= 86 | ated accordingly. If you have any questions or need further assistance, ple= 87 | ase feel free to reach out. 88 |

Thank you for your prompt payment.

Best regards,

Librar= 89 | yMan

=20 90 |


97 | 98 |
99 | -------------------------------------------------------------------------------- /notification-docs/Welcome to LibraryMan! 🎉.eml: -------------------------------------------------------------------------------- 1 | Date: Thu, 29 Aug 2024 22:48:39 +0530 (IST) 2 | From: hello@libraryman.com 3 | To: ajay@ajaynegi.co 4 | Message-ID: <558559546.14.1724951919617@localhost> 5 | Subject: =?utf-8?Q?Welcome_to_LibraryMan!_=F0=9F=8E=89?= 6 | MIME-Version: 1.0 7 | Content-Type: text/html;charset=utf-8 8 | Content-Transfer-Encoding: quoted-printable 9 | 10 |
12 | 13 | 14 | 15 | 18 | 19 | 46 | 47 |
20 | =20 21 | 24 | 25 | 42 | 43 |
26 | 28 | 29 | 32 | 38 | 39 |
30 | =20 31 | 34 | Welcome to LibraryMan! =F0=9F=8E=89 37 |
40 | 41 |
44 | =20 45 |
48 | 51 | 52 | 53 | 64 | 65 | 66 |
54 | =20 55 | 57 | 58 | 61 |
60 |
62 | =20 63 |
67 | 68 | 69 | 70 | 73 | 74 | 75 | 76 | 77 | 78 | 90 | 91 | 92 | 93 | 94 | 95 |


80 | =20 81 |

Hi Ajay Negi,

We=E2=80=99re excited to welcome you to Li= 84 | braryMan! Your account has been successfully created, and you=E2=80=99re no= 85 | w part of our community of book lovers. =F0=9F=93=9A

Feel free to ex= 86 | plore our vast collection of books and other resources. If you have any que= 87 | stions or need assistance, our team is here to help.

Happy reading! = 88 | =F0=9F=93=96

Best regards,

LibraryMan

=20 89 |


96 | 97 |
98 | -------------------------------------------------------------------------------- /notification-docs/Reminder_ Due date approaching ⏰.eml: -------------------------------------------------------------------------------- 1 | Date: Thu, 29 Aug 2024 22:54:27 +0530 (IST) 2 | From: hello@libraryman.com 3 | To: ajay@ajaynegi.co 4 | Message-ID: <327354607.20.1724952267196@localhost> 5 | Subject: =?utf-8?Q?Reminder:_Due_date_approaching_=E2=8F=B0?= 6 | MIME-Version: 1.0 7 | Content-Type: text/html;charset=utf-8 8 | Content-Transfer-Encoding: quoted-printable 9 | 10 |
12 | 13 | 14 | 15 | 18 | 19 | 46 | 47 |
20 | =20 21 | 24 | 25 | 42 | 43 |
26 | 28 | 29 | 32 | 38 | 39 |
30 | =20 31 | 34 | Reminder: Due date approaching =E2=8F=B0 37 |
40 | 41 |
44 | =20 45 |
48 | 51 | 52 | 53 | 64 | 65 | 66 |
54 | =20 55 | 57 | 58 | 61 |
60 |
62 | =20 63 |
67 | 68 | 69 | 70 | 73 | 74 | 75 | 76 | 77 | 78 | 90 | 91 | 92 | 93 | 94 | 95 |


80 | =20 81 |

Hi Ajay Negi,

This is a friendly reminder that the due d= 84 | ate to return 'Head First Java' book is approaching. Please ensure that you= 85 | return the book by Thu Aug 29 22:54:20 IST 2024 to avoid any late fees. = 86 | =F0=9F=93=85

If you need more time, consider renewing your book thro= 87 | ugh our online portal or by contacting us.

Thank you, and happy read= 88 | ing! =F0=9F=98=8A

Best regards,

LibraryMan

=20 89 |


96 | 97 |
98 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/email/EmailService.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.email; 2 | 3 | import com.libraryman_api.notification.NotificationRepository; 4 | import com.libraryman_api.notification.NotificationStatus; 5 | import com.libraryman_api.notification.Notifications; 6 | import jakarta.mail.MessagingException; 7 | import jakarta.mail.internet.MimeMessage; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.beans.factory.annotation.Value; 11 | import org.springframework.mail.javamail.JavaMailSender; 12 | import org.springframework.mail.javamail.MimeMessageHelper; 13 | import org.springframework.scheduling.annotation.Async; 14 | import org.springframework.stereotype.Service; 15 | 16 | /** 17 | * Unified service class for sending emails asynchronously. 18 | * Handles both general email sending and notifications. 19 | */ 20 | @Service 21 | public class EmailService implements EmailSender { 22 | 23 | private static final Logger LOGGER = LoggerFactory.getLogger(EmailService.class); 24 | 25 | private final NotificationRepository notificationRepository; 26 | private final JavaMailSender mailSender; 27 | 28 | @Value("${spring.mail.properties.domain_name}") // Domain name from application properties 29 | private String domainName; 30 | 31 | public EmailService(NotificationRepository notificationRepository, JavaMailSender mailSender) { 32 | this.notificationRepository = notificationRepository; 33 | this.mailSender = mailSender; 34 | } 35 | 36 | /** 37 | * Sends a general email asynchronously. 38 | * 39 | * @param to recipient's email 40 | * @param body email content (HTML supported) 41 | * @param subject subject of the email 42 | */ 43 | @Async 44 | public void sendEmail(String to, String body, String subject) { 45 | sendEmail(to, body, subject, null); // Default 'from' to null 46 | } 47 | 48 | /** 49 | * Sends a general email asynchronously. 50 | * 51 | * @param to recipient's email 52 | * @param body email content (HTML supported) 53 | * @param subject subject of the email 54 | * @param from sender's email address (overrides default if provided) 55 | */ 56 | @Async 57 | public void sendEmail(String to, String body, String subject, String from) { 58 | try { 59 | MimeMessage mimeMessage = mailSender.createMimeMessage(); 60 | MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, "utf-8"); 61 | 62 | helper.setText(body, true); // true = enable HTML content 63 | helper.setTo(to); 64 | helper.setSubject(subject); 65 | helper.setFrom(from != null ? from : domainName); // Use provided sender or default domain 66 | 67 | mailSender.send(mimeMessage); 68 | } catch (MessagingException e) { 69 | LOGGER.error("Failed to send email", e); 70 | throw new IllegalStateException("Failed to send email", e); 71 | } 72 | } 73 | 74 | /** 75 | * Sends a notification email and updates notification status. 76 | * 77 | * @param to recipient's email 78 | * @param email email content 79 | * @param subject subject of the email 80 | * @param notification notification entity to update status 81 | */ 82 | @Override 83 | @Async 84 | public void send(String to, String email, String subject, Notifications notification) { 85 | try { 86 | sendEmail(to, email, subject); // Reuse sendEmail method for notifications 87 | 88 | // Update notification status to SENT 89 | notification.setNotificationStatus(NotificationStatus.SENT); 90 | notificationRepository.save(notification); 91 | } catch (Exception e) { 92 | LOGGER.error("Failed to send notification email", e); 93 | 94 | // Update notification status to FAILED 95 | notification.setNotificationStatus(NotificationStatus.FAILED); 96 | notificationRepository.save(notification); 97 | 98 | throw new IllegalStateException("Failed to send notification email", e); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /notification-docs/Account Deletion Confirmation 🗑️.eml: -------------------------------------------------------------------------------- 1 | Date: Thu, 29 Aug 2024 22:52:24 +0530 (IST) 2 | From: hello@libraryman.com 3 | To: ajay@ajaynegi.co 4 | Message-ID: <1092009655.16.1724952144870@localhost> 5 | Subject: =?utf-8?Q?Account_Deletion_Confirmation_=F0=9F=97=91=EF=B8=8F?= 6 | MIME-Version: 1.0 7 | Content-Type: text/html;charset=utf-8 8 | Content-Transfer-Encoding: quoted-printable 9 | 10 |
12 | 13 | 14 | 15 | 18 | 19 | 47 | 48 |
20 | =20 21 | 24 | 25 | 43 | 44 |
26 | 28 | 29 | 32 | 39 | 40 |
30 | =20 31 | 34 | Account Deletion Confirmation =F0=9F=97=91=EF=B8=8F 38 |
41 | 42 |
45 | =20 46 |
49 | 52 | 53 | 54 | 65 | 66 | 67 |
55 | =20 56 | 58 | 59 | 62 |
61 |
63 | =20 64 |
68 | 69 | 70 | 71 | 74 | 75 | 76 | 77 | 78 | 79 | 91 | 92 | 93 | 94 | 95 | 96 |


81 | =20 82 |

Hi Ajay Negi,

We=E2=80=99re sorry to see you go! Your ac= 85 | count with LibraryMan has been successfully deleted as per your request.
If you change your mind in the future, you=E2=80=99re always welcome t= 87 | o create a new account with us. Should you have any questions or concerns, = 88 | please don=E2=80=99t hesitate to reach out.

Thank you for being a pa= 89 | rt of our community.

Best regards,

LibraryMan

=20 90 |


97 | 98 |
99 | -------------------------------------------------------------------------------- /notification-docs/Book Borrowed Successfully 🎉.eml: -------------------------------------------------------------------------------- 1 | Date: Thu, 29 Aug 2024 22:54:22 +0530 (IST) 2 | From: hello@libraryman.com 3 | To: ajay@ajaynegi.co 4 | Message-ID: <1576307238.19.1724952262731@localhost> 5 | Subject: =?utf-8?Q?Book_Borrowed_Successfully_=F0=9F=8E=89?= 6 | MIME-Version: 1.0 7 | Content-Type: text/html;charset=utf-8 8 | Content-Transfer-Encoding: quoted-printable 9 | 10 |
12 | 13 | 14 | 15 | 18 | 19 | 46 | 47 |
20 | =20 21 | 24 | 25 | 42 | 43 |
26 | 28 | 29 | 32 | 38 | 39 |
30 | =20 31 | 34 | Book Borrowed Successfully =F0=9F=8E=89 37 |
40 | 41 |
44 | =20 45 |
48 | 51 | 52 | 53 | 64 | 65 | 66 |
54 | =20 55 | 57 | 58 | 61 |
60 |
62 | =20 63 |
67 | 68 | 69 | 70 | 73 | 74 | 75 | 76 | 77 | 78 | 92 | 93 | 94 | 95 | 96 | 97 |


80 | =20 81 |

Hi Ajay Negi,

Congratulations! =F0=9F=8E=89 You have suc= 84 | cessfully borrowed 'Head First Java' book on Thu Aug 29 22:54:20 IST 2024.<= 85 | br>
You now have 15 days to enjoy reading it. We kindly request that you= 86 | return it to us on or before Fri Sep 13 22:54:20 IST 2024 to avoid any lat= 87 | e fees =F0=9F=93=86, which are =E2=82=B910 per day for late returns.
If you need to renew the book or have any questions, please don't hesitate= 89 | to reach out to us.

Thank you for choosing our library!

Best = 90 | regards,

LibraryMan

=20 91 |


98 | 99 |
100 | -------------------------------------------------------------------------------- /notification-docs/Overdue Fine Imposed ‼️.eml: -------------------------------------------------------------------------------- 1 | Date: Thu, 29 Aug 2024 22:57:10 +0530 (IST) 2 | From: hello@libraryman.com 3 | To: ajay@ajaynegi.co 4 | Message-ID: <533743081.21.1724952430896@localhost> 5 | Subject: =?utf-8?Q?Overdue_Fine_Imposed_=E2=80=BC=EF=B8=8F?= 6 | MIME-Version: 1.0 7 | Content-Type: text/html;charset=utf-8 8 | Content-Transfer-Encoding: quoted-printable 9 | 10 |
12 | 13 | 14 | 15 | 18 | 19 | 46 | 47 |
20 | =20 21 | 24 | 25 | 42 | 43 |
26 | 28 | 29 | 32 | 38 | 39 |
30 | =20 31 | 34 | Overdue Fine Imposed =E2=80=BC=EF=B8=8F 37 |
40 | 41 |
44 | =20 45 |
48 | 51 | 52 | 53 | 64 | 65 | 66 |
54 | =20 55 | 57 | 58 | 61 |
60 |
62 | =20 63 |
67 | 68 | 69 | 70 | 73 | 74 | 75 | 76 | 77 | 78 | 92 | 93 | 94 | 95 | 96 | 97 |


80 | =20 81 |

Hi Ajay Negi,

We hope you enjoyed reading 'The Great Gat= 84 | sby' book. Unfortunately, our records show that the book was returned after= 85 | the due date of 2024-08-20 22:54:20.537 . As a result, a fine of =E2=82=B9= 86 | 10 per day has been imposed for the late return.

The total fine amou= 87 | nt for this overdue return is =E2=82=B990.

If you have any questions= 88 | or would like to discuss this matter further, please don't hesitate to con= 89 | tact us.

Thank you for your understanding and for being a valued mem= 90 | ber of our library.

Best regards,

LibraryMan

=20 91 |


98 | 99 |
100 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/security/config/WebConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.config; 2 | 3 | import com.libraryman_api.security.jwt.JwtAuthenticationFilter; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.security.authentication.AuthenticationManager; 7 | import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; 8 | import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 9 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 10 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 11 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 12 | import org.springframework.security.web.SecurityFilterChain; 13 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 14 | import org.springframework.web.cors.CorsConfiguration; 15 | import org.springframework.web.cors.CorsConfigurationSource; 16 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 17 | import org.springframework.web.filter.CorsFilter; 18 | 19 | import static org.springframework.security.config.Customizer.withDefaults; 20 | import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; 21 | 22 | @Configuration 23 | @EnableWebSecurity(debug = true) 24 | // Do not use (debug=true) in a production system! as this contain sensitive information. 25 | @EnableMethodSecurity(prePostEnabled = true) 26 | public class WebConfiguration { 27 | 28 | private final JwtAuthenticationFilter jwtFilter; 29 | 30 | public WebConfiguration(JwtAuthenticationFilter jwtFilter) { 31 | this.jwtFilter = jwtFilter; 32 | } 33 | 34 | @Bean 35 | public SecurityFilterChain web(HttpSecurity http) throws Exception { 36 | http 37 | .csrf(AbstractHttpConfigurer::disable) 38 | .authorizeHttpRequests((request) -> request 39 | // make sure it is in order to access the proper Url 40 | .requestMatchers("/api/signup").permitAll() 41 | .requestMatchers("/api/login").permitAll() 42 | .requestMatchers("/api/logout").permitAll() 43 | .requestMatchers("/api/get-all-books/**").permitAll() 44 | .requestMatchers("/api/book/search**").permitAll() 45 | .requestMatchers("/api/analytics/**").hasAnyRole("ADMIN", "LIBRARIAN") // New line for analytics 46 | .anyRequest().authenticated() 47 | 48 | ) 49 | .logout(logout -> logout 50 | .deleteCookies("LibraryManCookie")) 51 | .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) 52 | .formLogin(withDefaults()); 53 | 54 | http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) 55 | .httpBasic(httpBasic -> { 56 | }); 57 | 58 | http.oauth2Login(withDefaults()); 59 | return http.build(); 60 | } 61 | 62 | @Bean 63 | public AuthenticationManager authenticationManager(AuthenticationConfiguration builder) throws Exception { 64 | return builder.getAuthenticationManager(); 65 | } 66 | 67 | @Bean 68 | public CorsConfigurationSource corsConfigurationSource() { 69 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 70 | CorsConfiguration corsConfiguration = new CorsConfiguration(); 71 | corsConfiguration.setAllowCredentials(true); 72 | corsConfiguration.addAllowedOriginPattern("*"); 73 | corsConfiguration.addAllowedHeader("Authorization"); 74 | corsConfiguration.addAllowedHeader("Content-Type"); 75 | corsConfiguration.addAllowedHeader("Accept"); 76 | corsConfiguration.addAllowedMethod("POST"); 77 | corsConfiguration.addAllowedMethod("PUT"); 78 | corsConfiguration.addAllowedMethod("GET"); 79 | corsConfiguration.addAllowedMethod("DELETE"); 80 | corsConfiguration.addAllowedMethod("OPTIONS"); 81 | corsConfiguration.setMaxAge(3600L); 82 | 83 | source.registerCorsConfiguration("/**", corsConfiguration); 84 | return source; 85 | } 86 | 87 | @Bean 88 | public CorsFilter corsFilter() { 89 | return new CorsFilter(corsConfigurationSource()); 90 | } 91 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LibraryMan: Library Management Simplified 📚 2 | 3 | LibraryMan is a user-friendly software solution for schools, companies, and libraries to efficiently manage book collections, track borrowing, and monitor due dates. It streamlines operations, reduces errors, and enhances the lending experience, making it easy to maintain a well-organized library. 4 | 5 | #### Checkout [Frontend](https://github.com/ajaynegi45/LibraryMan) Repository 6 | 7 | ## Project Structure 📂 8 | Checkout [Project Structure](https://github.com/ajaynegi45/LibraryMan-API/tree/main/project-structure) Diagram 9 | 10 | ## API Endpoints 🔗 11 | 12 | #### Learn More 13 | Want to know more about our API endpoints? Check out our [API Documentation](https://github.com/ajaynegi45/LibraryMan-API/tree/main/api-docs) for detailed information. 14 | 15 | #### Test Endpoints 16 | Ready to try out our API endpoints? Use [Postman Documentation](https://documenter.getpostman.com/view/28691426/2sAXjJ6D7L) to test and explore our APIs. 17 | 18 | ## How to Run the Project 💨 19 | 20 | 1. Ensure you have Java and MySQL installed on your system. 21 | 2. Clone or download the project from the repository. 22 | 3. Import the project into your preferred IDE (e.g., Eclipse, IntelliJ). 23 | 4. Set up the MySQL database and update the database configurations in the `application-development.properties` file. 24 | 5. Build and run the project using the IDE or by running `mvn spring-boot:run` command from the project root directory. 25 | 26 | ## ‼️ Important Note ‼️ 27 | 28 | - You need to set up the database and make sure the application properties are correctly configured to run the project successfully. 29 | 30 | ## Upcoming Update 31 | Adding more features, error handling, authentication, and security measures. 32 | 33 | ## Contributing 🤗 34 | 35 | Feel free to explore and use these project. If you encounter any issues or have suggestions for improvements, please feel free to contribute or reach out for assistance. 36 | 37 | Contributions are always welcome! ✨ 38 | 39 | See [`contributing.md`](https://github.com/ajaynegi45/Library-API/blob/main/Contributing.md) for ways to get started. 40 | 41 | Please adhere to this project's [`code_of_conduct.md`](https://github.com/ajaynegi45/Library-API/blob/main/code_of_conduct.md). 42 | 43 | ## Contact Information 📧 44 | 45 | If you have any questions or would like to connect, please don't hesitate to reach out. I'd be more than happy to chat and learn from your experiences too. 46 |

47 | **LinkedIn:** [Connect with me](https://www.linkedin.com/in/ajaynegi45/) 48 |

49 | 50 | ## Contributors 51 | 52 | 53 | --- 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
🌟 Stars🍴 Forks🐛 Issues🔔 Open PRs🔕 Close PRs
StarsForksIssuesOpen Pull RequestsClose Pull Requests
75 | 76 | 77 | ### Stargazers 78 | 79 |

80 | If you like LibraryMan Project, please star this repository to show your support! 🤩 81 |
82 | 83 | 84 | 85 | Star History Chart 86 | 87 |

88 | 89 | 90 | ## Thankyou ❤️ 91 | Thank you for taking the time to explore my project. I hope you find them informative and useful in your journey to learn Java and enhance your programming skills. Your support and contributions are highly appreciated. 92 | Happy coding! ✨ 93 |

94 | 95 | 96 | -------------------------------------------------------------------------------- /src/test/java/com/libraryman_api/email/EmailServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.email; 2 | 3 | import com.libraryman_api.notification.NotificationRepository; 4 | import com.libraryman_api.notification.NotificationStatus; 5 | import com.libraryman_api.notification.Notifications; 6 | import jakarta.mail.Message; 7 | import jakarta.mail.MessagingException; 8 | import jakarta.mail.Session; 9 | import jakarta.mail.internet.MimeMessage; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.Nested; 12 | import org.junit.jupiter.api.Test; 13 | import org.junit.jupiter.api.extension.ExtendWith; 14 | import org.mockito.ArgumentCaptor; 15 | import org.mockito.Captor; 16 | import org.mockito.InjectMocks; 17 | import org.mockito.Mock; 18 | import org.mockito.junit.jupiter.MockitoExtension; 19 | import org.springframework.mail.javamail.JavaMailSender; 20 | 21 | import java.lang.reflect.Field; 22 | 23 | import static org.junit.jupiter.api.Assertions.*; 24 | import static org.mockito.Mockito.verify; 25 | import static org.mockito.Mockito.when; 26 | 27 | /** 28 | * Tests the {@link EmailService}. 29 | */ 30 | @ExtendWith(MockitoExtension.class) 31 | class EmailServiceTest { 32 | @Mock 33 | private NotificationRepository notificationRepository; 34 | @Mock 35 | private JavaMailSender mailSender; 36 | @InjectMocks 37 | private EmailService emailService; 38 | 39 | @Captor 40 | private ArgumentCaptor notificationCaptor; 41 | @Captor 42 | private ArgumentCaptor mimeMessageCaptor; 43 | 44 | @BeforeEach 45 | void setup() throws Exception { 46 | injectPropertyValue(emailService); 47 | when(mailSender.createMimeMessage()).thenReturn(new MimeMessage((Session) null)); 48 | } 49 | 50 | private void injectPropertyValue(Object emailService) throws Exception { 51 | Field field = emailService.getClass().getDeclaredField("domainName"); 52 | field.setAccessible(true); 53 | field.set(emailService, "default.io"); 54 | } 55 | 56 | @Nested 57 | class SendEmail { 58 | @Test 59 | void withFrom() throws MessagingException { 60 | emailService.sendEmail("to@example.com", "

Hello

", "Subject", "sender@libraryman.com"); 61 | 62 | verify(mailSender).send(mimeMessageCaptor.capture()); 63 | MimeMessage sentMessage = mimeMessageCaptor.getValue(); 64 | assertEquals("to@example.com", sentMessage.getRecipients(Message.RecipientType.TO)[0].toString()); 65 | assertEquals("Subject", sentMessage.getSubject()); 66 | assertEquals("sender@libraryman.com", sentMessage.getFrom()[0].toString()); 67 | } 68 | 69 | @Test 70 | void withoutFrom_usesDefaultDomain() throws MessagingException { 71 | emailService.sendEmail("to@example.com", "

Hello

", "Subject"); 72 | 73 | verify(mailSender).send(mimeMessageCaptor.capture()); 74 | MimeMessage sentMessage = mimeMessageCaptor.getValue(); 75 | assertEquals("to@example.com", sentMessage.getRecipients(Message.RecipientType.TO)[0].toString()); 76 | assertEquals("Subject", sentMessage.getSubject()); 77 | assertEquals("default.io", sentMessage.getFrom()[0].toString()); 78 | } 79 | 80 | @Test 81 | void invalidToEmail_throwsException() { 82 | String invalidToEmail = ""; 83 | 84 | IllegalStateException ex = assertThrows(IllegalStateException.class, () -> emailService.sendEmail(invalidToEmail, "Body", "Subject", "sender@libraryman.com")); 85 | 86 | assertTrue(ex.getMessage().contains("Failed to send email")); 87 | } 88 | } 89 | 90 | @Nested 91 | class Send { 92 | @Test 93 | void success() { 94 | emailService.send("to@example.com", "

Hello

", "Subject", new Notifications()); 95 | 96 | verify(notificationRepository).save(notificationCaptor.capture()); 97 | assertEquals(NotificationStatus.SENT, notificationCaptor.getValue().getNotificationStatus()); 98 | } 99 | 100 | @Test 101 | void invalidToEmail_updatesStatusToFailed() { 102 | String invalidToEmail = ""; 103 | 104 | IllegalStateException ex = assertThrows(IllegalStateException.class, () -> emailService.send(invalidToEmail, "Email", "Subject", new Notifications())); 105 | 106 | assertTrue(ex.getMessage().contains("Failed to send notification email")); 107 | verify(notificationRepository).save(notificationCaptor.capture()); 108 | assertEquals(NotificationStatus.FAILED, notificationCaptor.getValue().getNotificationStatus()); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/exception/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.http.ResponseEntity; 5 | import org.springframework.validation.FieldError; 6 | import org.springframework.validation.ObjectError; 7 | import org.springframework.web.bind.MethodArgumentNotValidException; 8 | import org.springframework.web.bind.annotation.ControllerAdvice; 9 | import org.springframework.web.bind.annotation.ExceptionHandler; 10 | import org.springframework.web.context.request.WebRequest; 11 | 12 | import java.util.Date; 13 | import java.util.HashMap; 14 | import java.util.List; 15 | import java.util.Map; 16 | 17 | /** 18 | * Global exception handler for the LibraryMan API. This class provides 19 | * centralized exception handling across all controllers in the application. It 20 | * handles specific exceptions and returns appropriate HTTP responses. 21 | */ 22 | @ControllerAdvice 23 | public class GlobalExceptionHandler { 24 | 25 | /** 26 | * Handles {@link ResourceNotFoundException} exceptions. This method is 27 | * triggered when a {@code ResourceNotFoundException} is thrown in the 28 | * application. It constructs an {@link ErrorDetails} object containing the 29 | * exception details and returns a {@link ResponseEntity} with an HTTP status of 30 | * {@code 404 Not Found}. 31 | * 32 | * @param ex the exception that was thrown. 33 | * @param request the current web request in which the exception was thrown. 34 | * @return a {@link ResponseEntity} containing the {@link ErrorDetails} and an 35 | * HTTP status of {@code 404 Not Found}. 36 | */ 37 | @ExceptionHandler(ResourceNotFoundException.class) 38 | public ResponseEntity resourceNotFoundException(ResourceNotFoundException ex, WebRequest request) { 39 | ErrorDetails errorDetails = new ErrorDetails(new Date(), ex.getMessage(), request.getDescription(false)); 40 | return new ResponseEntity<>(errorDetails, HttpStatus.NOT_FOUND); 41 | } 42 | 43 | /** 44 | * Handles {@link InvalidSortFieldException} exceptions. This method is 45 | * triggered when an {@code InvalidSortFieldException} is thrown in the 46 | * application. It constructs an {@link ErrorDetails} object containing the 47 | * exception details and returns a {@link ResponseEntity} with an HTTP status of 48 | * {@code 400 Bad Request}. 49 | * 50 | * @param ex the exception that was thrown. 51 | * @param request the current web request in which the exception was thrown. 52 | * @return a {@link ResponseEntity} containing the {@link ErrorDetails} and an 53 | * HTTP status of {@code 400 Bad Request}. 54 | */ 55 | @ExceptionHandler(InvalidSortFieldException.class) 56 | public ResponseEntity invalidSortFieldException(InvalidSortFieldException ex, WebRequest request) { 57 | ErrorDetails errorDetails = new ErrorDetails(new Date(), ex.getMessage(), request.getDescription(false)); 58 | return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST); 59 | } 60 | 61 | /** 62 | * Handles {@link InvalidPasswordException} exceptions. This method is triggered 63 | * when an {@code InvalidPasswordException} is thrown in the application. It 64 | * constructs an {@link ErrorDetails} object containing the exception details 65 | * and returns a {@link ResponseEntity} with an HTTP status of 66 | * {@code 400 Bad Request}. 67 | * 68 | * @param ex the exception that was thrown. 69 | * @param request the current web request in which the exception was thrown. 70 | * @return a {@link ResponseEntity} containing the {@link ErrorDetails} and an 71 | * HTTP status of {@code 400 Bad Request}. 72 | */ 73 | @ExceptionHandler(InvalidPasswordException.class) 74 | public ResponseEntity invalidPasswordException(InvalidPasswordException ex, WebRequest request) { 75 | ErrorDetails errorDetails = new ErrorDetails(new Date(), ex.getMessage(), request.getDescription(false)); 76 | return new ResponseEntity<>(errorDetails, HttpStatus.BAD_REQUEST); 77 | } 78 | 79 | @ExceptionHandler(MethodArgumentNotValidException.class) 80 | public ResponseEntity> MethodArgumentNotValidException(MethodArgumentNotValidException ex) { 81 | List allErrors = ex.getBindingResult().getAllErrors(); 82 | HashMap map = new HashMap<>(); 83 | allErrors.forEach(objectError -> { 84 | String message = objectError.getDefaultMessage(); 85 | String field = ((FieldError) objectError).getField(); 86 | map.put(field, message); 87 | }); 88 | 89 | return new ResponseEntity<>(map, HttpStatus.BAD_REQUEST); 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /Code_of_Conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Behavior that contributes to a positive environment for our community that every one follow, includes: 12 | 13 | - Demonstrating empathy and kindness toward other people 14 | - Being respectful of differing opinions, viewpoints, and experiences 15 | - Giving and gracefully accepting constructive feedback 16 | - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | - Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | - The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | - Trolling, insulting or derogatory comments, and personal or political attacks 23 | - Public or private harassment 24 | - Publishing others' private information, such as a physical or email address, without their explicit permission 25 | - Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and it also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [your email address]. All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | 1. Correction 48 | 49 | Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 51 | 52 | 2. Warning 53 | 54 | Community Impact: A violation through a single incident or series of actions. 55 | Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 56 | 57 | 3. Temporary Ban 58 | 59 | Community Impact: A serious violation of community standards, including sustained inappropriate behavior. 60 | Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 61 | 62 | 4. Permanent Ban 63 | 64 | Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 65 | 66 | Consequence: A permanent ban from any sort of public interaction within the community. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. 71 | -------------------------------------------------------------------------------- /src/test/java/com/libraryman_api/newsletter/NewsletterServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.newsletter; 2 | 3 | import com.libraryman_api.TestUtil; 4 | import com.libraryman_api.email.EmailService; 5 | import org.junit.jupiter.api.Nested; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.mockito.InjectMocks; 9 | import org.mockito.Mock; 10 | import org.mockito.junit.jupiter.MockitoExtension; 11 | 12 | import java.util.Optional; 13 | 14 | import static org.junit.jupiter.api.Assertions.assertEquals; 15 | import static org.mockito.ArgumentMatchers.any; 16 | import static org.mockito.ArgumentMatchers.eq; 17 | import static org.mockito.Mockito.*; 18 | 19 | /** 20 | * Tests the {@link NewsletterService}. 21 | */ 22 | @ExtendWith(MockitoExtension.class) 23 | class NewsletterServiceTest { 24 | @Mock 25 | private NewsletterSubscriberRepository subscriberRepository; 26 | @Mock 27 | private EmailService emailService; 28 | @InjectMocks 29 | private NewsletterService newsletterService; 30 | 31 | @Nested 32 | class Subscribe { 33 | @Test 34 | void success() { 35 | NewsletterSubscriber newsletterSubscriber = TestUtil.getNewsletterSubscriber(); 36 | String email = newsletterSubscriber.getEmail(); 37 | when(subscriberRepository.findByEmail(any())).thenReturn(Optional.of(newsletterSubscriber)); 38 | 39 | String result = newsletterService.subscribe(email); 40 | 41 | assertEquals("You have successfully re-subscribed!", result); 42 | verify(subscriberRepository).save(newsletterSubscriber); 43 | verify(emailService).sendEmail(eq(email), any(), any()); 44 | } 45 | 46 | @Test 47 | void alreadySubscribed() { 48 | NewsletterSubscriber newsletterSubscriber = TestUtil.getNewsletterSubscriber(); 49 | newsletterSubscriber.setActive(true); 50 | String email = newsletterSubscriber.getEmail(); 51 | when(subscriberRepository.findByEmail(any())).thenReturn(Optional.of(newsletterSubscriber)); 52 | 53 | String result = newsletterService.subscribe(email); 54 | 55 | assertEquals("Email is already subscribed.", result); 56 | verify(subscriberRepository, never()).save(newsletterSubscriber); 57 | verify(emailService, never()).sendEmail(eq(email), any(), any()); 58 | } 59 | 60 | @Test 61 | void noSubscriberFound() { 62 | String email = "wendy@outlook.com"; 63 | when(subscriberRepository.findByEmail(any())).thenReturn(Optional.empty()); 64 | 65 | String result = newsletterService.subscribe(email); 66 | 67 | assertEquals("You have successfully subscribed!", result); 68 | verify(subscriberRepository).save(any()); 69 | verify(emailService).sendEmail(eq(email), any(), any()); 70 | } 71 | 72 | @Test 73 | void invalidEmail() { 74 | String email = "wendy@outlookcom"; 75 | 76 | String result = newsletterService.subscribe(email); 77 | 78 | assertEquals("Invalid email format.", result); 79 | } 80 | } 81 | 82 | @Nested 83 | class Unsubscribe { 84 | @Test 85 | void success() { 86 | NewsletterSubscriber newsletterSubscriber = TestUtil.getNewsletterSubscriber(); 87 | newsletterSubscriber.setActive(true); 88 | String token = newsletterSubscriber.getUnsubscribeToken(); 89 | when(subscriberRepository.findByUnsubscribeToken(any())).thenReturn(Optional.of(newsletterSubscriber)); 90 | 91 | String result = newsletterService.unsubscribe(token); 92 | 93 | assertEquals("You have successfully unsubscribed!", result); 94 | verify(subscriberRepository).save(newsletterSubscriber); 95 | verify(emailService).sendEmail(eq(newsletterSubscriber.getEmail()), any(), any()); 96 | } 97 | 98 | @Test 99 | void invalidToken() { 100 | when(subscriberRepository.findByUnsubscribeToken(any())).thenReturn(Optional.empty()); 101 | 102 | String result = newsletterService.unsubscribe("token"); 103 | 104 | assertEquals("Invalid or expired token.", result); 105 | verify(subscriberRepository, never()).save(any()); 106 | verify(emailService, never()).sendEmail(any(), any(), any()); 107 | } 108 | 109 | @Test 110 | void alreadyUnsubscribed() { 111 | NewsletterSubscriber newsletterSubscriber = TestUtil.getNewsletterSubscriber(); 112 | newsletterSubscriber.setActive(false); 113 | String token = newsletterSubscriber.getUnsubscribeToken(); 114 | when(subscriberRepository.findByUnsubscribeToken(any())).thenReturn(Optional.of(newsletterSubscriber)); 115 | 116 | String result = newsletterService.unsubscribe(token); 117 | 118 | assertEquals("You are already unsubscribed.", result); 119 | verify(subscriberRepository, never()).save(any()); 120 | verify(emailService, never()).sendEmail(any(), any(), any()); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/book/BookController.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.book; 2 | 3 | import com.libraryman_api.exception.ResourceNotFoundException; 4 | import jakarta.validation.Valid; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.data.domain.Page; 7 | import org.springframework.data.domain.PageRequest; 8 | import org.springframework.data.domain.Pageable; 9 | import org.springframework.data.domain.Sort; 10 | import org.springframework.data.web.PageableDefault; 11 | import org.springframework.http.ResponseEntity; 12 | import org.springframework.security.access.prepost.PreAuthorize; 13 | import org.springframework.web.bind.annotation.*; 14 | 15 | /** 16 | * REST controller for managing books in the LibraryMan application. 17 | * This controller provides endpoints for performing CRUD operations on books, 18 | * including retrieving all books, getting a book by its ID, adding a new book, 19 | * updating an existing book, and deleting a book. 20 | */ 21 | @RestController 22 | @RequestMapping("/api") 23 | public class BookController { 24 | 25 | @Autowired 26 | private BookService bookService; 27 | 28 | /** 29 | * Retrieves a paginated and sorted list of all books in the library. 30 | * 31 | * @param pageable contains pagination information (page number, size, and sorting). 32 | * @param sortBy (optional) the field by which to sort the results. 33 | * @param sortDir (optional) the direction of sorting (asc or desc). Defaults to ascending. 34 | * @return a {@link Page} of {@link BookDto} objects representing the books in the library. 35 | * The results are sorted by title by default and limited to 5 books per page. 36 | */ 37 | @GetMapping("/get-all-books") 38 | public Page getAllBooks(@PageableDefault(page = 0, size = 5, sort = "title") Pageable pageable, 39 | @RequestParam(required = false) String sortBy, 40 | @RequestParam(required = false) String sortDir) { 41 | 42 | // Adjust the pageable based on dynamic sorting parameters 43 | if (sortBy != null && !sortBy.isEmpty()) { 44 | Sort.Direction direction = Sort.Direction.ASC; // Default direction 45 | 46 | if (sortDir != null && sortDir.equalsIgnoreCase("desc")) { 47 | direction = Sort.Direction.DESC; 48 | } 49 | 50 | pageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(direction, sortBy)); 51 | } 52 | return bookService.getAllBooks(pageable); 53 | } 54 | 55 | /** 56 | * Retrieves a book by its ID. 57 | * 58 | * @param id the ID of the book to retrieve. 59 | * @return a {@link ResponseEntity} containing the {@link Book} object, if found. 60 | * @throws ResourceNotFoundException if the book with the specified ID is not found. 61 | */ 62 | @GetMapping("get-book-by-id/{id}") 63 | public ResponseEntity getBookById(@PathVariable int id) { 64 | return bookService.getBookById(id) 65 | .map(ResponseEntity::ok) 66 | .orElseThrow(() -> new ResourceNotFoundException("Book not found")); 67 | } 68 | 69 | /** 70 | * Adds a new book to the library. 71 | * 72 | * @param bookDto the {@link Book} object representing the new book to add. 73 | * @return the added {@link Book} object. 74 | */ 75 | @PostMapping("/add-book") 76 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN')") 77 | public BookDto addBook(@Valid @RequestBody BookDto bookDto) { 78 | return bookService.addBook(bookDto); 79 | } 80 | 81 | /** 82 | * Updates an existing book in the library. 83 | * 84 | * @param id the ID of the book to update. 85 | * @param bookDtoDetails the {@link Book} object containing the updated book details. 86 | * @return the updated {@link Book} object. 87 | */ 88 | @PutMapping("update-book/{id}") 89 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN')") 90 | public BookDto updateBook(@PathVariable int id, @Valid @RequestBody BookDto bookDtoDetails) { 91 | return bookService.updateBook(id, bookDtoDetails); 92 | } 93 | 94 | /** 95 | * Deletes a book from the library by its ID. 96 | * 97 | * @param id the ID of the book to delete. 98 | */ 99 | @DeleteMapping("delete-book/{id}") 100 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN')") 101 | public void deleteBook(@PathVariable int id) { 102 | bookService.deleteBook(id); 103 | } 104 | 105 | /** 106 | * Searches book based on title, author, genre, etc. 107 | * It uses a keyword parameter to filter the books, and pagination is applied to the search results. 108 | * If no book is found it will return 204(No content found) http response. 109 | * If keyword is null then it will return all books. 110 | * 111 | * @param keyword the Keyword to search Book 112 | * @param pageable 113 | * @return 114 | */ 115 | @GetMapping("book/search") 116 | public ResponseEntity> searchBook(@RequestParam String keyword, @PageableDefault(page = 0, size = 5, sort = "title") Pageable pageable) { 117 | Page books = bookService.searchBook(keyword, pageable); 118 | if (!books.isEmpty()) 119 | return ResponseEntity.ok(books); 120 | return ResponseEntity.noContent().build(); 121 | } 122 | } -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/member/MemberController.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.member; 2 | 3 | import com.libraryman_api.exception.ResourceNotFoundException; 4 | import com.libraryman_api.member.dto.MembersDto; 5 | import com.libraryman_api.member.dto.UpdateMembersDto; 6 | import com.libraryman_api.member.dto.UpdatePasswordDto; 7 | import jakarta.validation.Valid; 8 | import org.springframework.data.domain.Page; 9 | import org.springframework.data.domain.PageRequest; 10 | import org.springframework.data.domain.Pageable; 11 | import org.springframework.data.domain.Sort; 12 | import org.springframework.data.web.PageableDefault; 13 | import org.springframework.http.ResponseEntity; 14 | import org.springframework.security.access.prepost.PreAuthorize; 15 | import org.springframework.web.bind.annotation.*; 16 | 17 | /** 18 | * REST controller for managing library members. 19 | * This controller provides endpoints for performing CRUD operations on members. 20 | */ 21 | @RestController 22 | @RequestMapping("/api") 23 | public class MemberController { 24 | 25 | private final MemberService memberService; 26 | 27 | /** 28 | * Constructs a new {@code MemberController} with the specified {@link MemberService}. 29 | * 30 | * @param memberService the service to handle member-related operations 31 | */ 32 | public MemberController(MemberService memberService) { 33 | this.memberService = memberService; 34 | } 35 | 36 | /** 37 | * Retrieves a paginated and sorted list of all library members. 38 | * 39 | * @param pageable contains pagination information (page number, size, and sorting). 40 | * @param sortBy (optional) the field by which to sort the results. 41 | * @param sortDir (optional) the direction of sorting (asc or desc). Defaults to ascending. 42 | * @return a {@link Page} of {@link Members} representing all members in the library. 43 | * The results are sorted by name by default and limited to 5 members per page. 44 | */ 45 | @GetMapping("/get-all-members") 46 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN')") 47 | public Page getAllMembers(@PageableDefault(page = 0, size = 5, sort = "name") Pageable pageable, 48 | @RequestParam(required = false) String sortBy, 49 | @RequestParam(required = false) String sortDir) { 50 | 51 | // Adjust the pageable based on dynamic sorting parameters 52 | if (sortBy != null && !sortBy.isEmpty()) { 53 | Sort.Direction direction = Sort.Direction.ASC; // Default direction 54 | 55 | if (sortDir != null && sortDir.equalsIgnoreCase("desc")) { 56 | direction = Sort.Direction.DESC; 57 | } 58 | 59 | pageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(direction, sortBy)); 60 | } 61 | 62 | return memberService.getAllMembers(pageable); 63 | } 64 | 65 | /** 66 | * Retrieves a library member by their ID. 67 | * If the member is not found, a {@link ResourceNotFoundException} is thrown. 68 | * 69 | * @param id the ID of the member to retrieve 70 | * @return a {@link ResponseEntity} containing the found {@link Members} object 71 | */ 72 | @GetMapping("/get-member-by-id/{id}") 73 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN')") 74 | public ResponseEntity getMemberById(@PathVariable int id) { 75 | return memberService.getMemberById(id) 76 | .map(ResponseEntity::ok) 77 | .orElseThrow(() -> new ResourceNotFoundException("Member not found")); 78 | } 79 | 80 | /** 81 | * Updates an existing library member. 82 | * If the member is not found, a {@link ResourceNotFoundException} is thrown. 83 | * 84 | * @param id the ID of the member to update 85 | * @param membersDtoDetails the {@link Members} object containing the updated details 86 | * @return the updated {@link Members} object 87 | */ 88 | @PutMapping("/update-member-by-id/{id}") 89 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN') or (hasRole('USER') and #id == authentication.principal.memberId)") 90 | public MembersDto updateMember(@PathVariable int id, @Valid @RequestBody UpdateMembersDto membersDtoDetails) { 91 | return memberService.updateMember(id, membersDtoDetails); 92 | } 93 | 94 | /** 95 | * Deletes a library member by their ID. 96 | * If the member is not found, a {@link ResourceNotFoundException} is thrown. 97 | * 98 | * @param id the ID of the member to delete 99 | */ 100 | @DeleteMapping("/delete-member-by-id/{id}") 101 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN')") 102 | public void deleteMember(@PathVariable int id) { 103 | memberService.deleteMember(id); 104 | } 105 | 106 | /** 107 | * Updates the password for a library member. 108 | * If the member is not found or the update fails, an appropriate exception will be thrown. 109 | * 110 | * @param id the ID of the member whose password is to be updated 111 | * @param updatePasswordDto the {@link UpdatePasswordDto} object containing the password details 112 | * @return a {@link ResponseEntity} containing a success message indicating the password was updated successfully 113 | */ 114 | @PutMapping("/update-password-by-id/{id}") 115 | @PreAuthorize("#id == authentication.principal.memberId") 116 | public ResponseEntity updatePassword(@PathVariable int id, 117 | @Valid @RequestBody UpdatePasswordDto updatePasswordDto) { 118 | memberService.updatePassword(id, updatePasswordDto); 119 | return ResponseEntity.ok("Password updated successfully."); 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /src/test/java/com/libraryman_api/TestUtil.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api; 2 | 3 | import com.libraryman_api.book.Book; 4 | import com.libraryman_api.book.BookDto; 5 | import com.libraryman_api.borrowing.Borrowings; 6 | import com.libraryman_api.borrowing.BorrowingsDto; 7 | import com.libraryman_api.fine.Fine; 8 | import com.libraryman_api.member.Members; 9 | import com.libraryman_api.member.Role; 10 | import com.libraryman_api.member.dto.MembersDto; 11 | import com.libraryman_api.member.dto.UpdateMembersDto; 12 | import com.libraryman_api.member.dto.UpdatePasswordDto; 13 | import com.libraryman_api.newsletter.NewsletterSubscriber; 14 | import com.libraryman_api.security.model.LoginRequest; 15 | import org.springframework.data.domain.*; 16 | 17 | import java.math.BigDecimal; 18 | import java.util.Calendar; 19 | import java.util.Date; 20 | import java.util.List; 21 | import java.util.Random; 22 | 23 | public class TestUtil { 24 | private static final Random randomNumberUtils = new Random(); 25 | 26 | public static int getRandomInt() { 27 | return randomNumberUtils.nextInt(100, 1000); 28 | } 29 | 30 | public static Book getBook() { 31 | Book book = new Book(); 32 | book.setBookId(Constant.BOOK_ID); 33 | book.setTitle(Constant.BOOK_TITLE); 34 | book.setAuthor("Sarah Maas"); 35 | book.setIsbn("978-0-7475-3269-9"); 36 | book.setPublisher("Penguin Random House"); 37 | book.setPublishedYear(2025); 38 | book.setGenre("Fiction"); 39 | book.setCopiesAvailable(5); 40 | return book; 41 | } 42 | 43 | public static BookDto getBookDto() { 44 | BookDto inputDto = new BookDto(); 45 | inputDto.setBookId(Constant.BOOK_ID); 46 | inputDto.setTitle(Constant.BOOK_TITLE); 47 | inputDto.setAuthor("Sarah Maas"); 48 | inputDto.setIsbn("978-0-7475-3269-9"); 49 | inputDto.setPublisher("Penguin Random House"); 50 | inputDto.setPublishedYear(2025); 51 | inputDto.setGenre("Fiction"); 52 | inputDto.setCopiesAvailable(5); 53 | return inputDto; 54 | } 55 | 56 | public static BorrowingsDto getBorrowingsDto() { 57 | BorrowingsDto borrowingsDto = new BorrowingsDto(); 58 | borrowingsDto.setBorrowingId(1); 59 | borrowingsDto.setBook(getBookDto()); 60 | borrowingsDto.setFine(null); 61 | borrowingsDto.setMember(getMembersDto()); 62 | borrowingsDto.setBorrowDate(new Date()); 63 | borrowingsDto.setReturnDate(adjustDays(new Date(), 7)); 64 | return borrowingsDto; 65 | } 66 | 67 | public static Fine getFine() { 68 | Fine fine = new Fine(); 69 | fine.setAmount(BigDecimal.valueOf(100.00)); 70 | fine.setPaid(false); 71 | return fine; 72 | } 73 | 74 | public static MembersDto getMembersDto() { 75 | MembersDto membersDto = new MembersDto(); 76 | membersDto.setMemberId(Constant.MEMBER_ID); 77 | membersDto.setName("Jane Doe"); 78 | membersDto.setUsername("Jane01"); 79 | membersDto.setEmail("jane.doe@gmail.com"); 80 | membersDto.setPassword("password"); 81 | membersDto.setRole(Role.USER); 82 | membersDto.setMembershipDate(new Date()); 83 | return membersDto; 84 | } 85 | 86 | public static UpdateMembersDto getUpdateMembersDto() { 87 | UpdateMembersDto updateMembersDto = new UpdateMembersDto(); 88 | updateMembersDto.setName("David Green"); 89 | updateMembersDto.setUsername("DGreen"); 90 | updateMembersDto.setEmail("david.green@gmail.com"); 91 | return updateMembersDto; 92 | } 93 | 94 | public static Borrowings getBorrowings() { 95 | Borrowings borrowings = new Borrowings(); 96 | borrowings.setBook(getBook()); 97 | borrowings.setMember(getMembers()); 98 | borrowings.setBorrowDate(new Date()); 99 | borrowings.setDueDate(adjustDays(new Date(), 14)); 100 | borrowings.setReturnDate(null); 101 | return borrowings; 102 | } 103 | 104 | public static Date adjustDays(Date date, int days) { 105 | Calendar calendar = Calendar.getInstance(); 106 | calendar.setTime(date); 107 | calendar.add(Calendar.DAY_OF_MONTH, days); 108 | return calendar.getTime(); 109 | } 110 | 111 | public static Pageable getPageRequest(String sortBy) { 112 | return PageRequest.of(0, 10, Sort.by(sortBy)); 113 | } 114 | 115 | public static Page getBookPage() { 116 | return new PageImpl<>(List.of(getBook())); 117 | } 118 | 119 | public static Members getMembers() { 120 | Members members = new Members(); 121 | members.setMemberId(Constant.MEMBER_ID); 122 | members.setUsername("John01"); 123 | members.setName("John Doe"); 124 | members.setEmail("john@gmail.com"); 125 | members.setPassword("password"); 126 | members.setRole(Role.USER); 127 | members.setMembershipDate(new Date()); 128 | return members; 129 | } 130 | 131 | public static UpdatePasswordDto getUpdatePasswordDto() { 132 | UpdatePasswordDto updatePasswordDto = new UpdatePasswordDto(); 133 | updatePasswordDto.setCurrentPassword("password"); 134 | updatePasswordDto.setNewPassword("newPassword"); 135 | return updatePasswordDto; 136 | } 137 | 138 | public static NewsletterSubscriber getNewsletterSubscriber() { 139 | NewsletterSubscriber newsletterSubscriber = new NewsletterSubscriber(); 140 | newsletterSubscriber.setEmail("emma@hotmail.com"); 141 | newsletterSubscriber.setActive(false); 142 | return newsletterSubscriber; 143 | } 144 | 145 | public static LoginRequest getLoginRequest() { 146 | LoginRequest request = new LoginRequest(); 147 | request.setUsername("username"); 148 | request.setPassword("password"); 149 | return request; 150 | } 151 | 152 | public static class Constant { 153 | public static final int BOOK_ID = 11; 154 | public static final int BORROWING_ID = 22; 155 | public static final int MEMBER_ID = 33; 156 | public static final String BOOK_TITLE = "Test Book"; 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/main/java/com/libraryman_api/borrowing/BorrowingController.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.borrowing; 2 | 3 | import com.libraryman_api.exception.ResourceNotFoundException; 4 | import jakarta.validation.Valid; 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.PageRequest; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.domain.Sort; 9 | import org.springframework.data.web.PageableDefault; 10 | import org.springframework.security.access.prepost.PreAuthorize; 11 | import org.springframework.web.bind.annotation.*; 12 | 13 | /** 14 | * REST controller for managing borrowings in the LibraryMan application. 15 | * This controller provides endpoints for performing operations related to borrowing and returning books, 16 | * paying fines, and retrieving borrowing records. 17 | */ 18 | @RestController 19 | @RequestMapping("/api") 20 | public class BorrowingController { 21 | 22 | private final BorrowingService borrowingService; 23 | 24 | /** 25 | * Constructs a new {@code BorrowingController} with the specified {@link BorrowingService}. 26 | * 27 | * @param borrowingService the service used to handle borrowing-related operations. 28 | */ 29 | public BorrowingController(BorrowingService borrowingService) { 30 | this.borrowingService = borrowingService; 31 | } 32 | 33 | /** 34 | * Retrieves a paginated and sorted list of all borrowing records in the library. 35 | * 36 | * @param pageable contains pagination information (page number, size, and sorting). 37 | * @param sortBy (optional) the field by which to sort the results. 38 | * @param sortDir (optional) the direction of sorting (asc or desc). Defaults to ascending. 39 | * @return a {@link Page} of {@link Borrowings} representing all borrowings. 40 | * The results are sorted by borrow date by default and limited to 5 members per page. 41 | */ 42 | @GetMapping("/get-all-borrowings") 43 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN')") 44 | public Page getAllBorrowings(@PageableDefault(page = 0, size = 5, sort = "borrowDate") Pageable pageable, 45 | @RequestParam(required = false) String sortBy, 46 | @RequestParam(required = false) String sortDir) { 47 | 48 | // Adjust the pageable based on dynamic sorting parameters 49 | if (sortBy != null && !sortBy.isEmpty()) { 50 | Sort.Direction direction = Sort.Direction.ASC; // Default direction 51 | 52 | if (sortDir != null && sortDir.equalsIgnoreCase("desc")) { 53 | direction = Sort.Direction.DESC; 54 | } 55 | 56 | pageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(direction, sortBy)); 57 | } 58 | 59 | return borrowingService.getAllBorrowings(pageable); 60 | } 61 | 62 | /** 63 | * Records a new book borrowing. 64 | * 65 | * @param borrowingsDto the {@link Borrowings} object containing borrowing details. 66 | * @return the saved {@link Borrowings} object representing the borrowing record. 67 | */ 68 | @PostMapping("/borrow-book") 69 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN') or (hasRole('USER') and #borrowingsDto.member.memberId == authentication.principal.memberId)") 70 | public BorrowingsDto borrowBook(@Valid @RequestBody BorrowingsDto borrowingsDto) { 71 | return borrowingService.borrowBook(borrowingsDto); 72 | } 73 | 74 | /** 75 | * Marks a borrowed book as returned. 76 | * 77 | * @param id the ID of the borrowing record to update. 78 | */ 79 | @PutMapping("/{id}/return-borrow-book") 80 | public BorrowingsDto returnBook(@PathVariable int id) { 81 | return borrowingService.returnBook(id); 82 | } 83 | 84 | /** 85 | * Pays the fine for an overdue book. 86 | * 87 | * @param id the ID of the borrowing record for which the fine is being paid. 88 | * @return a message indicating the payment status. 89 | */ 90 | @PutMapping("/borrowing/{id}/pay-fine") 91 | public String payFine(@PathVariable int id) { 92 | System.out.println("Pay Fine Id: " + id); 93 | return borrowingService.payFine(id); 94 | } 95 | 96 | /** 97 | * Retrieves a paginated and sorted list of all borrowing records for a specific member. 98 | * 99 | * @param memberId the ID of the member whose borrowing records are to be retrieved. 100 | * @param pageable contains pagination information (page number, size, and sorting). 101 | * @param sortBy (optional) the field by which to sort the results. 102 | * @param sortDir (optional) the direction of sorting (asc or desc). Defaults to ascending. 103 | * @return a {@link Page} of {@link Borrowings} representing all borrowings for a specific member. 104 | * The results are sorted by borrow date by default and limited to 5 members per page. 105 | */ 106 | @GetMapping("/get-all-borrowings-of-a-member/{memberId}") 107 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN') or (hasRole('USER') and #memberId == authentication.principal.memberId)") 108 | public Page getAllBorrowingsOfAMember(@PathVariable int memberId, 109 | @PageableDefault(page = 0, size = 5, sort = "borrowDate") Pageable pageable, 110 | @RequestParam(required = false) String sortBy, 111 | @RequestParam(required = false) String sortDir) { 112 | 113 | // Adjust the pageable based on dynamic sorting parameters 114 | if (sortBy != null && !sortBy.isEmpty()) { 115 | Sort.Direction direction = Sort.Direction.ASC; // Default direction 116 | 117 | if (sortDir != null && sortDir.equalsIgnoreCase("desc")) { 118 | direction = Sort.Direction.DESC; 119 | } 120 | 121 | pageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(direction, sortBy)); 122 | } 123 | 124 | return borrowingService.getAllBorrowingsOfMember(memberId, pageable); 125 | } 126 | 127 | /** 128 | * Retrieves a borrowing record by its ID. 129 | * 130 | * @param borrowingId the ID of the borrowing record to retrieve. 131 | * @return the {@link Borrowings} object representing the borrowing record. 132 | * @throws ResourceNotFoundException if the borrowing record with the specified ID is not found. 133 | */ 134 | @GetMapping("/get-borrowing-by-id/{borrowingId}") 135 | @PreAuthorize("hasRole('LIBRARIAN') or hasRole('ADMIN')") 136 | public BorrowingsDto getBorrowingById(@PathVariable int borrowingId) { 137 | return borrowingService.getBorrowingById(borrowingId) 138 | .orElseThrow(() -> new ResourceNotFoundException("Borrowing not found")); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/test/java/com/libraryman_api/security/jwt/JwtAuthenticationFilterTest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.security.jwt; 2 | 3 | import com.libraryman_api.TestUtil; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.Nested; 6 | import org.junit.jupiter.api.Test; 7 | import org.junit.jupiter.api.extension.ExtendWith; 8 | import org.junit.jupiter.params.ParameterizedTest; 9 | import org.junit.jupiter.params.provider.CsvSource; 10 | import org.mockito.InjectMocks; 11 | import org.mockito.Mock; 12 | import org.mockito.junit.jupiter.MockitoExtension; 13 | import org.springframework.mock.web.MockFilterChain; 14 | import org.springframework.mock.web.MockHttpServletRequest; 15 | import org.springframework.mock.web.MockHttpServletResponse; 16 | import org.springframework.security.core.Authentication; 17 | import org.springframework.security.core.context.SecurityContext; 18 | import org.springframework.security.core.context.SecurityContextHolder; 19 | import org.springframework.security.core.userdetails.UserDetailsService; 20 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 21 | 22 | import static org.junit.jupiter.api.Assertions.*; 23 | import static org.mockito.ArgumentMatchers.any; 24 | import static org.mockito.Mockito.mock; 25 | import static org.mockito.Mockito.when; 26 | 27 | /** 28 | * Tests the {@link JwtAuthenticationFilter}. 29 | */ 30 | @ExtendWith(MockitoExtension.class) 31 | class JwtAuthenticationFilterTest { 32 | @Mock 33 | private JwtAuthenticationHelper jwtAuthenticationHelper; 34 | @Mock 35 | private UserDetailsService userDetailsService; 36 | @InjectMocks 37 | private JwtAuthenticationFilter jwtAuthenticationFilter; 38 | 39 | @BeforeEach 40 | void setup() { 41 | SecurityContextHolder.clearContext(); 42 | } 43 | 44 | @Nested 45 | class DoFilterInternal { 46 | @Test 47 | void success() { 48 | MockHttpServletRequest request = new MockHttpServletRequest(); 49 | request.addHeader("Authorization", "Bearer valid.token.here"); 50 | 51 | when(jwtAuthenticationHelper.getUsernameFromToken(any())).thenReturn("user"); 52 | when(userDetailsService.loadUserByUsername(any())).thenReturn(TestUtil.getMembers()); 53 | when(jwtAuthenticationHelper.isTokenExpired(any())).thenReturn(false); 54 | 55 | assertDoesNotThrow(() -> jwtAuthenticationFilter.doFilterInternal(request, new MockHttpServletResponse(), new MockFilterChain())); 56 | 57 | assertNotNull(SecurityContextHolder.getContext().getAuthentication()); 58 | } 59 | 60 | @Test 61 | void noAuthorizationHeader() { 62 | MockHttpServletRequest request = new MockHttpServletRequest(); 63 | 64 | assertDoesNotThrow(() -> jwtAuthenticationFilter.doFilterInternal(request, new MockHttpServletResponse(), new MockFilterChain())); 65 | 66 | assertNull(SecurityContextHolder.getContext().getAuthentication()); 67 | } 68 | 69 | @Test 70 | void notBearer() { 71 | MockHttpServletRequest request = new MockHttpServletRequest(); 72 | request.addHeader("Authorization", "Basic encoded.credentials"); 73 | 74 | assertDoesNotThrow(() -> jwtAuthenticationFilter.doFilterInternal(request, new MockHttpServletResponse(), new MockFilterChain())); 75 | 76 | assertNull(SecurityContextHolder.getContext().getAuthentication()); 77 | } 78 | 79 | @ParameterizedTest 80 | @CsvSource({"io.jsonwebtoken.ExpiredJwtException", "io.jsonwebtoken.MalformedJwtException", "io.jsonwebtoken.SignatureException"}) 81 | void getUsernameFromToken_throwsException(String exceptionClassName) throws ClassNotFoundException { 82 | MockHttpServletRequest request = new MockHttpServletRequest(); 83 | request.addHeader("Authorization", "Bearer some.token.here"); 84 | Class exception = (Class) Class.forName(exceptionClassName); 85 | when(jwtAuthenticationHelper.getUsernameFromToken(any())).thenThrow(exception); 86 | 87 | assertThrows(exception, () -> jwtAuthenticationFilter.doFilterInternal(request, new MockHttpServletResponse(), new MockFilterChain())); 88 | 89 | assertNull(SecurityContextHolder.getContext().getAuthentication()); 90 | } 91 | 92 | @Test 93 | void nullUsername() { 94 | MockHttpServletRequest request = new MockHttpServletRequest(); 95 | request.addHeader("Authorization", "Bearer valid.token.here"); 96 | when(jwtAuthenticationHelper.getUsernameFromToken(any())).thenReturn(null); 97 | 98 | assertDoesNotThrow(() -> jwtAuthenticationFilter.doFilterInternal(request, new MockHttpServletResponse(), new MockFilterChain())); 99 | 100 | assertNull(SecurityContextHolder.getContext().getAuthentication()); 101 | } 102 | 103 | @Test 104 | void usernameNotFoundException() { 105 | MockHttpServletRequest request = new MockHttpServletRequest(); 106 | request.addHeader("Authorization", "Bearer valid.token.here"); 107 | 108 | when(jwtAuthenticationHelper.getUsernameFromToken(any())).thenReturn("user"); 109 | when(userDetailsService.loadUserByUsername(any())).thenThrow(UsernameNotFoundException.class); 110 | 111 | assertThrows(UsernameNotFoundException.class, () -> jwtAuthenticationFilter.doFilterInternal(request, new MockHttpServletResponse(), new MockFilterChain())); 112 | 113 | assertNull(SecurityContextHolder.getContext().getAuthentication()); 114 | } 115 | 116 | @Test 117 | void tokenExpired() { 118 | MockHttpServletRequest request = new MockHttpServletRequest(); 119 | request.addHeader("Authorization", "Bearer valid.token.here"); 120 | 121 | when(jwtAuthenticationHelper.getUsernameFromToken(any())).thenReturn("user"); 122 | when(userDetailsService.loadUserByUsername(any())).thenReturn(TestUtil.getMembers()); 123 | when(jwtAuthenticationHelper.isTokenExpired(any())).thenReturn(true); 124 | 125 | assertDoesNotThrow(() -> jwtAuthenticationFilter.doFilterInternal(request, new MockHttpServletResponse(), new MockFilterChain())); 126 | 127 | assertNull(SecurityContextHolder.getContext().getAuthentication()); 128 | } 129 | 130 | @Test 131 | void securityContextAlreadyExists() { 132 | MockHttpServletRequest request = new MockHttpServletRequest(); 133 | request.addHeader("Authorization", "Bearer valid.token.here"); 134 | 135 | when(jwtAuthenticationHelper.getUsernameFromToken(any())).thenReturn("user"); 136 | 137 | Authentication existingAuth = mock(Authentication.class); 138 | SecurityContext context = SecurityContextHolder.createEmptyContext(); 139 | context.setAuthentication(existingAuth); 140 | SecurityContextHolder.setContext(context); 141 | assertDoesNotThrow(() -> jwtAuthenticationFilter.doFilterInternal(request, new MockHttpServletResponse(), new MockFilterChain())); 142 | 143 | assertEquals(existingAuth, SecurityContextHolder.getContext().getAuthentication()); 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/test/java/com/libraryman_api/book/BookServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.libraryman_api.book; 2 | 3 | import com.libraryman_api.TestUtil; 4 | import com.libraryman_api.TestUtil.Constant; 5 | import com.libraryman_api.exception.InvalidSortFieldException; 6 | import com.libraryman_api.exception.ResourceNotFoundException; 7 | import org.junit.jupiter.api.Nested; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.extension.ExtendWith; 10 | import org.mockito.InjectMocks; 11 | import org.mockito.Mock; 12 | import org.mockito.junit.jupiter.MockitoExtension; 13 | import org.springframework.data.domain.Page; 14 | import org.springframework.data.domain.Pageable; 15 | import org.springframework.data.mapping.PropertyReferenceException; 16 | 17 | import java.util.Optional; 18 | 19 | import static org.junit.jupiter.api.Assertions.*; 20 | import static org.mockito.ArgumentMatchers.any; 21 | import static org.mockito.ArgumentMatchers.anyInt; 22 | import static org.mockito.Mockito.when; 23 | 24 | /** 25 | * Tests the {@link BookService} class. 26 | */ 27 | @ExtendWith(MockitoExtension.class) 28 | class BookServiceTest { 29 | @Mock 30 | private BookRepository bookRepository; 31 | @InjectMocks 32 | private BookService bookService; 33 | 34 | @Nested 35 | class GetAllBooks { 36 | @Test 37 | void success() { 38 | Pageable pageable = TestUtil.getPageRequest("title"); 39 | when(bookRepository.findAll(pageable)).thenReturn(TestUtil.getBookPage()); 40 | 41 | Page bookDtoPage = bookService.getAllBooks(pageable); 42 | 43 | assertEquals(1, bookDtoPage.getTotalElements()); 44 | assertEquals(Constant.BOOK_TITLE, bookDtoPage.getContent().get(0).getTitle()); 45 | } 46 | 47 | @Test 48 | void invalidSortField_throwsException() { 49 | Pageable pageable = TestUtil.getPageRequest("nonexistentField"); 50 | when(bookRepository.findAll(pageable)).thenThrow(PropertyReferenceException.class); 51 | 52 | Exception exception = assertThrows(InvalidSortFieldException.class, () -> bookService.getAllBooks(pageable)); 53 | 54 | assertEquals("The specified 'sortBy' value is invalid.", exception.getMessage()); 55 | } 56 | } 57 | 58 | @Nested 59 | class GetBookById { 60 | @Test 61 | void success() { 62 | when(bookRepository.findById(any())).thenReturn(Optional.of(TestUtil.getBook())); 63 | 64 | Optional bookDto = bookService.getBookById(Constant.BOOK_ID); 65 | 66 | assertTrue(bookDto.isPresent()); 67 | assertEquals(Constant.BOOK_TITLE, bookDto.get().getTitle()); 68 | } 69 | 70 | @Test 71 | void noBookFound_returnsEmpty() { 72 | int idNotInRepository = TestUtil.getRandomInt(); 73 | when(bookRepository.findById(anyInt())).thenReturn(Optional.empty()); 74 | 75 | Optional bookDto = bookService.getBookById(idNotInRepository); 76 | 77 | assertTrue(bookDto.isEmpty()); 78 | } 79 | } 80 | 81 | @Test 82 | void addBook() { 83 | BookDto bookDto = TestUtil.getBookDto(); 84 | when(bookRepository.save(any())).thenReturn(TestUtil.getBook()); 85 | 86 | BookDto bookDtoResult = bookService.addBook(bookDto); 87 | 88 | assertEquals(Constant.BOOK_TITLE, bookDtoResult.getTitle()); 89 | } 90 | 91 | @Nested 92 | class UpdateBook { 93 | @Test 94 | void success() { 95 | BookDto bookDtoDetails = TestUtil.getBookDto(); 96 | bookDtoDetails.setTitle("New Title"); 97 | Book existingBook = TestUtil.getBook(); 98 | existingBook.setTitle("Existing Title"); 99 | Book savedBook = TestUtil.getBook(); 100 | savedBook.setTitle(bookDtoDetails.getTitle()); 101 | when(bookRepository.findById(any())).thenReturn(Optional.of(existingBook)); 102 | when(bookRepository.save(any())).thenReturn(savedBook); 103 | 104 | BookDto bookDtoResult = bookService.updateBook(Constant.BOOK_ID, bookDtoDetails); 105 | 106 | assertEquals(bookDtoDetails.getTitle(), bookDtoResult.getTitle()); 107 | } 108 | 109 | @Test 110 | void noBookFound_throwsException() { 111 | int idNotInRepository = TestUtil.getRandomInt(); 112 | BookDto newBookDto = TestUtil.getBookDto(); 113 | when(bookRepository.findById(any())).thenReturn(Optional.empty()); 114 | 115 | Exception exception = assertThrows(ResourceNotFoundException.class, () -> bookService.updateBook(idNotInRepository, newBookDto)); 116 | 117 | assertEquals("Book not found", exception.getMessage()); 118 | } 119 | } 120 | 121 | @Nested 122 | class DeleteBook { 123 | @Test 124 | void success() { 125 | when(bookRepository.findById(any())).thenReturn(Optional.of(TestUtil.getBook())); 126 | 127 | assertDoesNotThrow(() -> bookService.deleteBook(Constant.BOOK_ID)); 128 | } 129 | 130 | @Test 131 | void noBookFound_throwsException() { 132 | int idNotInRepository = TestUtil.getRandomInt(); 133 | when(bookRepository.findById(anyInt())).thenReturn(Optional.empty()); 134 | 135 | Exception exception = assertThrows(ResourceNotFoundException.class, () -> bookService.deleteBook(idNotInRepository)); 136 | 137 | assertEquals("Book not found", exception.getMessage()); 138 | } 139 | } 140 | 141 | @Test 142 | void entityToDto() { 143 | Book book = TestUtil.getBook(); 144 | 145 | BookDto bookDto = bookService.EntityToDto(book); 146 | 147 | assertEquals(book.getBookId(), bookDto.getBookId()); 148 | assertEquals(book.getPublisher(), bookDto.getPublisher()); 149 | assertEquals(book.getPublishedYear(), bookDto.getPublishedYear()); 150 | assertEquals(book.getTitle(), bookDto.getTitle()); 151 | assertEquals(book.getAuthor(), bookDto.getAuthor()); 152 | assertEquals(book.getGenre(), bookDto.getGenre()); 153 | assertEquals(book.getIsbn(), bookDto.getIsbn()); 154 | assertEquals(book.getCopiesAvailable(), bookDto.getCopiesAvailable()); 155 | } 156 | 157 | @Test 158 | void dtoToEntity() { 159 | BookDto bookDto = TestUtil.getBookDto(); 160 | 161 | Book book = bookService.DtoToEntity(bookDto); 162 | 163 | assertEquals(bookDto.getBookId(), book.getBookId()); 164 | assertEquals(bookDto.getPublisher(), book.getPublisher()); 165 | assertEquals(bookDto.getPublishedYear(), book.getPublishedYear()); 166 | assertEquals(bookDto.getTitle(), book.getTitle()); 167 | assertEquals(bookDto.getAuthor(), book.getAuthor()); 168 | assertEquals(bookDto.getGenre(), book.getGenre()); 169 | assertEquals(bookDto.getIsbn(), book.getIsbn()); 170 | assertEquals(bookDto.getCopiesAvailable(), book.getCopiesAvailable()); 171 | } 172 | 173 | @Test 174 | void searchBook() { 175 | Pageable pageable = TestUtil.getPageRequest("title"); 176 | when(bookRepository.searchBook("keyword", pageable)).thenReturn(TestUtil.getBookPage()); 177 | 178 | Page bookPage = bookService.searchBook("keyword", pageable); 179 | 180 | assertEquals(Constant.BOOK_TITLE, bookPage.getContent().get(0).getTitle()); 181 | } 182 | } --------------------------------------------------------------------------------