From ab1e4a6a9644e65e1a7dcb300a02d9747a7b2b85 Mon Sep 17 00:00:00 2001 From: Fiona Friberg Date: Mon, 27 Apr 2026 14:14:16 +0200 Subject: [PATCH 1/9] feat: integrate spotless with maven and CI, apply code formatting --- .github/workflows/CI.yml | 2 +- pom.xml | 21 + .../org/example/alfs/AlfsApplication.java | 7 +- .../alfs/config/DemoDataInitializer.java | 10 +- .../alfs/config/GlobalModelAttributes.java | 51 +- .../example/alfs/config/PasswordConfig.java | 10 +- .../org/example/alfs/config/S3Properties.java | 89 +- .../example/alfs/config/SecurityConfig.java | 168 +- .../example/alfs/config/StorageConfig.java | 20 +- .../alfs/controllers/AdminController.java | 22 +- .../controllers/AttachmentController.java | 134 +- .../AttachmentDownloadController.java | 116 +- .../alfs/controllers/AuthController.java | 124 +- .../alfs/controllers/AuthViewController.java | 159 +- .../alfs/controllers/ErrorController.java | 28 +- .../alfs/controllers/StartPageController.java | 8 +- .../alfs/controllers/TestController.java | 104 +- .../controllers/TicketCommentController.java | 136 +- .../alfs/controllers/TicketController.java | 265 ++- .../dto/attachment/AttachmentViewDTO.java | 131 +- .../alfs/dto/auth/LoginRequestDTO.java | 19 +- .../alfs/dto/auth/LoginResponseDTO.java | 2 +- .../alfs/dto/auth/SignupRequestDTO.java | 20 +- .../alfs/dto/comment/CommentCreateDTO.java | 8 +- .../alfs/dto/comment/CommentViewDTO.java | 26 +- .../alfs/dto/ticket/TicketAssignDTO.java | 3 +- .../alfs/dto/ticket/TicketCreateDTO.java | 7 +- .../dto/ticket/TicketStatusUpdateDTO.java | 13 +- .../alfs/dto/ticket/TicketViewDTO.java | 34 +- .../org/example/alfs/entities/Attachment.java | 39 +- .../org/example/alfs/entities/AuditLog.java | 61 +- .../org/example/alfs/entities/Ticket.java | 78 +- .../example/alfs/entities/TicketComment.java | 44 +- .../java/org/example/alfs/entities/User.java | 31 +- .../org/example/alfs/enums/AuditAction.java | 14 +- .../java/org/example/alfs/enums/Role.java | 8 +- .../org/example/alfs/enums/TicketStatus.java | 8 +- .../exceptions/GlobalExceptionHandler.java | 41 +- .../alfs/mapper/TicketCommentMapper.java | 30 +- .../org/example/alfs/mapper/TicketMapper.java | 26 +- .../repositories/AttachmentRepository.java | 8 +- .../alfs/repositories/AuditLogRepository.java | 8 +- .../repositories/TicketCommentRepository.java | 16 +- .../alfs/repositories/TicketRepository.java | 31 +- .../alfs/repositories/UserRepository.java | 13 +- .../security/JwtAuthenticationFilter.java | 191 +- .../org/example/alfs/security/JwtService.java | 148 +- .../example/alfs/security/SecurityUtils.java | 55 +- .../alfs/services/AttachmentService.java | 220 ++- .../example/alfs/services/AuditService.java | 77 +- .../example/alfs/services/AuthService.java | 64 +- .../alfs/services/DemoDataService.java | 347 ++-- .../alfs/services/TicketCommentService.java | 174 +- .../example/alfs/services/TicketService.java | 553 +++--- .../example/alfs/services/UserService.java | 17 +- .../services/storage/MinioStorageService.java | 91 +- src/main/resources/application.properties | 2 + .../example/alfs/AlfsApplicationTests.java | 6 +- .../org/example/alfs/ArchitectureTest.java | 214 ++- .../alfs/controllers/TicketControllerIT.java | 375 ++-- .../alfs/services/AttachmentServiceTest.java | 453 ++--- .../alfs/services/AuthServiceTest.java | 233 ++- .../services/TicketCommentServiceTest.java | 830 ++++---- .../alfs/services/TicketServiceTest.java | 1667 +++++++++-------- .../alfs/services/UserServiceTest.java | 80 +- 65 files changed, 3979 insertions(+), 4011 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 8d0bb3c..fca0fe2 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -34,7 +34,7 @@ jobs: cache: maven - name: Build - run: mvn -B clean verify + run: mvn -B clean verify spotless:check - name: Publish test results uses: dorny/test-reporter@v3 diff --git a/pom.xml b/pom.xml index 01a9d63..bf26f34 100644 --- a/pom.xml +++ b/pom.xml @@ -173,6 +173,27 @@ + + com.diffplug.spotless + spotless-maven-plugin + 3.4.0 + + + + + + + + + + + + check + + verify + + + diff --git a/src/main/java/org/example/alfs/AlfsApplication.java b/src/main/java/org/example/alfs/AlfsApplication.java index a0c9f5d..e4eebe2 100644 --- a/src/main/java/org/example/alfs/AlfsApplication.java +++ b/src/main/java/org/example/alfs/AlfsApplication.java @@ -6,8 +6,7 @@ @SpringBootApplication public class AlfsApplication { - public static void main(String[] args) { - SpringApplication.run(AlfsApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(AlfsApplication.class, args); + } } diff --git a/src/main/java/org/example/alfs/config/DemoDataInitializer.java b/src/main/java/org/example/alfs/config/DemoDataInitializer.java index f2e0a1e..06bcb72 100644 --- a/src/main/java/org/example/alfs/config/DemoDataInitializer.java +++ b/src/main/java/org/example/alfs/config/DemoDataInitializer.java @@ -11,10 +11,10 @@ @RequiredArgsConstructor public class DemoDataInitializer implements CommandLineRunner { - private final DemoDataService demoDataService; + private final DemoDataService demoDataService; - @Override - public void run(String... args) { - demoDataService.seedDemoData(); - } + @Override + public void run(String... args) { + demoDataService.seedDemoData(); + } } diff --git a/src/main/java/org/example/alfs/config/GlobalModelAttributes.java b/src/main/java/org/example/alfs/config/GlobalModelAttributes.java index ffa835a..ccc6f96 100644 --- a/src/main/java/org/example/alfs/config/GlobalModelAttributes.java +++ b/src/main/java/org/example/alfs/config/GlobalModelAttributes.java @@ -1,37 +1,38 @@ package org.example.alfs.config; + +import org.example.alfs.security.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.example.alfs.security.SecurityUtils; import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ModelAttribute; @ControllerAdvice public class GlobalModelAttributes { - private static final Logger log = LoggerFactory.getLogger(GlobalModelAttributes.class); - private final SecurityUtils securityUtils; - - public GlobalModelAttributes(SecurityUtils securityUtils) { - this.securityUtils = securityUtils; - } + private static final Logger log = LoggerFactory.getLogger(GlobalModelAttributes.class); + private final SecurityUtils securityUtils; - @ModelAttribute - public void addGlobalAttributes(Model model) { + public GlobalModelAttributes(SecurityUtils securityUtils) { + this.securityUtils = securityUtils; + } - boolean isLoggedIn = false; - String username = null; - String role = null; - try { - var user = securityUtils.getCurrentUser(); - isLoggedIn = true; - username = user.getUsername(); - role = user.getRole().name(); - } catch (RuntimeException ex) { - log.debug("Could not resolve current user for global model attributes", ex); - } + @ModelAttribute + public void addGlobalAttributes(Model model) { - model.addAttribute("isLoggedIn", isLoggedIn); - model.addAttribute("username", username); - model.addAttribute("role", role); + boolean isLoggedIn = false; + String username = null; + String role = null; + try { + var user = securityUtils.getCurrentUser(); + isLoggedIn = true; + username = user.getUsername(); + role = user.getRole().name(); + } catch (RuntimeException ex) { + log.debug("Could not resolve current user for global model attributes", ex); } -} \ No newline at end of file + + model.addAttribute("isLoggedIn", isLoggedIn); + model.addAttribute("username", username); + model.addAttribute("role", role); + } +} diff --git a/src/main/java/org/example/alfs/config/PasswordConfig.java b/src/main/java/org/example/alfs/config/PasswordConfig.java index e319407..9cc26ce 100644 --- a/src/main/java/org/example/alfs/config/PasswordConfig.java +++ b/src/main/java/org/example/alfs/config/PasswordConfig.java @@ -8,9 +8,9 @@ @Configuration public class PasswordConfig { - // Makes password encoder available in the whole app - @Bean - public PasswordEncoder passwordEncoder(){ - return new BCryptPasswordEncoder(); - } + // Makes password encoder available in the whole app + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/java/org/example/alfs/config/S3Properties.java b/src/main/java/org/example/alfs/config/S3Properties.java index 4e4ed87..08c82e1 100644 --- a/src/main/java/org/example/alfs/config/S3Properties.java +++ b/src/main/java/org/example/alfs/config/S3Properties.java @@ -7,63 +7,58 @@ @Validated @ConfigurationProperties(prefix = "storage.s3") public class S3Properties { - @NotBlank - private String endpoint; - @NotBlank - private String accessKey; - @NotBlank - private String secretKey; - @NotBlank - private String bucket; - @NotBlank - private String region; - private boolean secure; + @NotBlank private String endpoint; + @NotBlank private String accessKey; + @NotBlank private String secretKey; + @NotBlank private String bucket; + @NotBlank private String region; + private boolean secure; - public String getEndpoint() { - return endpoint; - } + public String getEndpoint() { + return endpoint; + } - public void setEndpoint(String endpoint) { - this.endpoint = endpoint; - } + public void setEndpoint(String endpoint) { + this.endpoint = endpoint; + } - public String getAccessKey() { - return accessKey; - } + public String getAccessKey() { + return accessKey; + } - public void setAccessKey(String accessKey) { - this.accessKey = accessKey; - } + public void setAccessKey(String accessKey) { + this.accessKey = accessKey; + } - public String getSecretKey() { - return secretKey; - } + public String getSecretKey() { + return secretKey; + } - public void setSecretKey(String secretKey) { - this.secretKey = secretKey; - } + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } - public String getBucket() { - return bucket; - } + public String getBucket() { + return bucket; + } - public void setBucket(String bucket) { - this.bucket = bucket; - } + public void setBucket(String bucket) { + this.bucket = bucket; + } - public String getRegion() { - return region; - } + public String getRegion() { + return region; + } - public void setRegion(String region) { - this.region = region; - } + public void setRegion(String region) { + this.region = region; + } - public boolean isSecure() { - return secure; - } + public boolean isSecure() { + return secure; + } - public void setSecure(boolean secure) { - this.secure = secure; - } + public void setSecure(boolean secure) { + this.secure = secure; + } } diff --git a/src/main/java/org/example/alfs/config/SecurityConfig.java b/src/main/java/org/example/alfs/config/SecurityConfig.java index db3e32c..2d330ce 100644 --- a/src/main/java/org/example/alfs/config/SecurityConfig.java +++ b/src/main/java/org/example/alfs/config/SecurityConfig.java @@ -13,83 +13,99 @@ @Configuration @EnableMethodSecurity public class SecurityConfig { - private final JwtAuthenticationFilter jwtFilter; - - public SecurityConfig(JwtAuthenticationFilter jwtFilter) {this.jwtFilter = jwtFilter;} - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - - http - // Only for development (H2 console support) - .securityMatcher("/**") - - .csrf(csrf -> csrf.disable()) - - .headers(headers -> headers.frameOptions(frame -> frame.disable())) - - // stateless jwt - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) - - - // Authorization strategy: - // - JWT is used for authentication (identifying the user) - // - User roles are NOT trusted from the JWT - // - Roles are always loaded from the database - // This ensures that permission changes take effect immediately - .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/login").permitAll() - .requestMatchers("/auth/signup").permitAll() - .requestMatchers("/auth/logout").permitAll() - .requestMatchers("/auth/hash").permitAll() - .requestMatchers("/h2-console/**").permitAll() - .requestMatchers("/", "/startPage").permitAll() - .requestMatchers(HttpMethod.POST, "/tickets/create").permitAll() - .requestMatchers(HttpMethod.GET, "/tickets/create").permitAll() - .requestMatchers("/tickets/ticket-created").permitAll() - .requestMatchers("/tickets/token/**").permitAll() - .requestMatchers(HttpMethod.POST, "/tickets/*/comments").permitAll() - .requestMatchers("/error/**").permitAll() - - //swagger - .requestMatchers("/swagger-ui/**").permitAll() - .requestMatchers("/v3/api-docs/**").permitAll() - .requestMatchers("/swagger-ui.html").permitAll() - - //allow access to endpoints during development - .requestMatchers("/tickets/previewTicket").permitAll() - .requestMatchers(HttpMethod.POST, "/api/files/upload").permitAll() - .requestMatchers("/css/**", "/js/**", "/images/**", "/static/**").permitAll() - .requestMatchers("/login", "/login-form").permitAll() - .requestMatchers("/signup", "/signup-form").permitAll() - .anyRequest().authenticated() - ) - - .exceptionHandling(exception -> exception - - // Use custom error page for 403 (Spring Security access denied) - .accessDeniedHandler((request, response, ex) -> { - request.getRequestDispatcher("/error/403") - .forward(request, response); + private final JwtAuthenticationFilter jwtFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtFilter) { + this.jwtFilter = jwtFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + http + // Only for development (H2 console support) + .securityMatcher("/**") + .csrf(csrf -> csrf.disable()) + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + + // stateless jwt + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // Authorization strategy: + // - JWT is used for authentication (identifying the user) + // - User roles are NOT trusted from the JWT + // - Roles are always loaded from the database + // This ensures that permission changes take effect immediately + .authorizeHttpRequests( + auth -> + auth.requestMatchers("/auth/login") + .permitAll() + .requestMatchers("/auth/signup") + .permitAll() + .requestMatchers("/auth/logout") + .permitAll() + .requestMatchers("/auth/hash") + .permitAll() + .requestMatchers("/h2-console/**") + .permitAll() + .requestMatchers("/", "/startPage") + .permitAll() + .requestMatchers(HttpMethod.POST, "/tickets/create") + .permitAll() + .requestMatchers(HttpMethod.GET, "/tickets/create") + .permitAll() + .requestMatchers("/tickets/ticket-created") + .permitAll() + .requestMatchers("/tickets/token/**") + .permitAll() + .requestMatchers(HttpMethod.POST, "/tickets/*/comments") + .permitAll() + .requestMatchers("/error/**") + .permitAll() + + // swagger + .requestMatchers("/swagger-ui/**") + .permitAll() + .requestMatchers("/v3/api-docs/**") + .permitAll() + .requestMatchers("/swagger-ui.html") + .permitAll() + + // allow access to endpoints during development + .requestMatchers("/tickets/previewTicket") + .permitAll() + .requestMatchers(HttpMethod.POST, "/api/files/upload") + .permitAll() + .requestMatchers("/css/**", "/js/**", "/images/**", "/static/**") + .permitAll() + .requestMatchers("/login", "/login-form") + .permitAll() + .requestMatchers("/signup", "/signup-form") + .permitAll() + .anyRequest() + .authenticated()) + .exceptionHandling( + exception -> + exception + + // Use custom error page for 403 (Spring Security access denied) + .accessDeniedHandler( + (request, response, ex) -> { + request.getRequestDispatcher("/error/403").forward(request, response); }) - // Use custom error page for 401 (unauthorized) - .authenticationEntryPoint((request, response, ex) -> { - request.getRequestDispatcher("/error/401") - .forward(request, response); - }) - ) - - - // disable DEFAULT LOGIN - .formLogin(form -> form.disable()) - .httpBasic(basic -> basic.disable()) - + // Use custom error page for 401 (unauthorized) + .authenticationEntryPoint( + (request, response, ex) -> { + request.getRequestDispatcher("/error/401").forward(request, response); + })) - .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + // disable DEFAULT LOGIN + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); - return http.build(); - } -} \ No newline at end of file + return http.build(); + } +} diff --git a/src/main/java/org/example/alfs/config/StorageConfig.java b/src/main/java/org/example/alfs/config/StorageConfig.java index fcadc90..2a62751 100644 --- a/src/main/java/org/example/alfs/config/StorageConfig.java +++ b/src/main/java/org/example/alfs/config/StorageConfig.java @@ -9,14 +9,14 @@ @EnableConfigurationProperties(S3Properties.class) public class StorageConfig { - @Bean - public MinioClient minioClient(S3Properties props) { - // Note: TLS is controlled by the scheme in props.getEndpoint() (http:// or https://) - // The props.getSecure() flag should be reflected in the endpoint URL scheme - return MinioClient.builder() - .endpoint(props.getEndpoint()) - .credentials(props.getAccessKey(), props.getSecretKey()) - .region(props.getRegion()) - .build(); - } + @Bean + public MinioClient minioClient(S3Properties props) { + // Note: TLS is controlled by the scheme in props.getEndpoint() (http:// or https://) + // The props.getSecure() flag should be reflected in the endpoint URL scheme + return MinioClient.builder() + .endpoint(props.getEndpoint()) + .credentials(props.getAccessKey(), props.getSecretKey()) + .region(props.getRegion()) + .build(); + } } diff --git a/src/main/java/org/example/alfs/controllers/AdminController.java b/src/main/java/org/example/alfs/controllers/AdminController.java index e3e624f..a2e30d7 100644 --- a/src/main/java/org/example/alfs/controllers/AdminController.java +++ b/src/main/java/org/example/alfs/controllers/AdminController.java @@ -9,16 +9,16 @@ @Controller public class AdminController { - private final TicketService ticketService; + private final TicketService ticketService; - public AdminController(TicketService ticketService) { - this.ticketService = ticketService; - } + public AdminController(TicketService ticketService) { + this.ticketService = ticketService; + } - @PreAuthorize("hasRole('ADMIN')") - @GetMapping("/admin/tickets") - public String getAllTicketsForAdmin(Model model) { - model.addAttribute("tickets", ticketService.getAllTickets()); - return "admin-tickets"; - } -} \ No newline at end of file + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/admin/tickets") + public String getAllTicketsForAdmin(Model model) { + model.addAttribute("tickets", ticketService.getAllTickets()); + return "admin-tickets"; + } +} diff --git a/src/main/java/org/example/alfs/controllers/AttachmentController.java b/src/main/java/org/example/alfs/controllers/AttachmentController.java index 6d068fa..29cfe55 100644 --- a/src/main/java/org/example/alfs/controllers/AttachmentController.java +++ b/src/main/java/org/example/alfs/controllers/AttachmentController.java @@ -1,4 +1,6 @@ package org.example.alfs.controllers; + +import java.util.Map; import org.example.alfs.dto.attachment.AttachmentViewDTO; import org.example.alfs.entities.User; import org.example.alfs.security.SecurityUtils; @@ -11,83 +13,91 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import java.util.Map; @Controller @RequestMapping("/api/files") public class AttachmentController { - private final AttachmentService attachmentService; - private final SecurityUtils securityUtils; - - public AttachmentController(AttachmentService attachmentService, SecurityUtils securityUtils) { - this.attachmentService = attachmentService; - this.securityUtils = securityUtils; - } + private final AttachmentService attachmentService; + private final SecurityUtils securityUtils; - @PostMapping("/upload") - public String upload(@RequestParam("ticketId") Long ticketId, - @RequestParam("file") MultipartFile file, - @RequestParam(required = false) String token) throws Exception { + public AttachmentController(AttachmentService attachmentService, SecurityUtils securityUtils) { + this.attachmentService = attachmentService; + this.securityUtils = securityUtils; + } - if (ticketId == null || ticketId <= 0) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid ticketId"); - } + @PostMapping("/upload") + public String upload( + @RequestParam("ticketId") Long ticketId, + @RequestParam("file") MultipartFile file, + @RequestParam(required = false) String token) + throws Exception { - if (file == null || file.isEmpty()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty"); - } + if (ticketId == null || ticketId <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid ticketId"); + } - var user = getCurrentUserOrNull(); + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File is empty"); + } - attachmentService.uploadToTicket(ticketId, file, user, token); + var user = getCurrentUserOrNull(); - if (token != null && !token.isBlank()) { - return "redirect:/tickets/token/" + token; - } + attachmentService.uploadToTicket(ticketId, file, user, token); - return "redirect:/tickets/" + ticketId; + if (token != null && !token.isBlank()) { + return "redirect:/tickets/token/" + token; } - // List attachments for a ticket - @org.springframework.web.bind.annotation.GetMapping - public ResponseEntity listByTicket(@RequestParam(name = "ticketId") Long ticketId) { - if (ticketId <= 0) { - return ResponseEntity.badRequest().body(Map.of( - "error", "Invalid ticketId", - "detail", "Provide a positive numeric ticketId as a query parameter" - )); - } - - var attachments = attachmentService.getAttachmentsByTicketId(ticketId); - - var dtoList = attachments.stream().map(att -> new AttachmentViewDTO( - att.getId(), - att.getTicket() != null ? att.getTicket().getId() : null, - att.getFileName(), - "/api/files/" + att.getId() + "/download", - att.getUploadedAt(), - att.getUploadedBy() != null ? att.getUploadedBy().getUsername() : "Anonymous" - )).toList(); - - return ResponseEntity.ok(dtoList); + return "redirect:/tickets/" + ticketId; + } + + // List attachments for a ticket + @org.springframework.web.bind.annotation.GetMapping + public ResponseEntity listByTicket(@RequestParam(name = "ticketId") Long ticketId) { + if (ticketId <= 0) { + return ResponseEntity.badRequest() + .body( + Map.of( + "error", "Invalid ticketId", + "detail", "Provide a positive numeric ticketId as a query parameter")); } - private User getCurrentUserOrNull() { - try { - return securityUtils.getCurrentUser(); - } catch (RuntimeException ex) { - String message = ex.getMessage(); - - boolean authFailure = - "No authenticated user in security context".equals(message) || - "Authenticated user not found in database".equals(message); - - if (authFailure) { - return null; - } - - throw ex; - } + var attachments = attachmentService.getAttachmentsByTicketId(ticketId); + + var dtoList = + attachments.stream() + .map( + att -> + new AttachmentViewDTO( + att.getId(), + att.getTicket() != null ? att.getTicket().getId() : null, + att.getFileName(), + "/api/files/" + att.getId() + "/download", + att.getUploadedAt(), + att.getUploadedBy() != null + ? att.getUploadedBy().getUsername() + : "Anonymous")) + .toList(); + + return ResponseEntity.ok(dtoList); + } + + private User getCurrentUserOrNull() { + try { + return securityUtils.getCurrentUser(); + } catch (RuntimeException ex) { + String message = ex.getMessage(); + + boolean authFailure = + "No authenticated user in security context".equals(message) + || "Authenticated user not found in database".equals(message); + + if (authFailure) { + return null; + } + + throw ex; } + } } diff --git a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java index 1736bbf..4c27ecd 100644 --- a/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java +++ b/src/main/java/org/example/alfs/controllers/AttachmentDownloadController.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.nio.charset.StandardCharsets; import org.example.alfs.entities.Attachment; import org.example.alfs.services.AttachmentService; import org.example.alfs.services.storage.MinioStorageService; @@ -20,78 +21,73 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; -import java.nio.charset.StandardCharsets; - @RestController @RequestMapping("/api/files") public class AttachmentDownloadController { - private final AttachmentService attachmentService; - private final MinioStorageService storageService; + private final AttachmentService attachmentService; + private final MinioStorageService storageService; - public AttachmentDownloadController(AttachmentService attachmentService, - MinioStorageService storageService) { - this.attachmentService = attachmentService; - this.storageService = storageService; - } + public AttachmentDownloadController( + AttachmentService attachmentService, MinioStorageService storageService) { + this.attachmentService = attachmentService; + this.storageService = storageService; + } - @GetMapping("/{id}/download") - @Operation( - summary = "Download attachment", - description = "Downloads a file attached to a ticket." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "File downloaded successfully", - content = @Content( - mediaType = "application/octet-stream", - schema = @Schema(type = "string", format = "binary") - ) - ), - @ApiResponse(responseCode = "403", description = "Access denied"), - @ApiResponse(responseCode = "404", description = "Attachment not found"), - @ApiResponse(responseCode = "502", description = "Failed to fetch file from storage") - }) - public ResponseEntity download( - @Parameter(description = "ID of the attachment", example = "1") - @PathVariable Long id - ) { + @GetMapping("/{id}/download") + @Operation( + summary = "Download attachment", + description = "Downloads a file attached to a ticket.") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "File downloaded successfully", + content = + @Content( + mediaType = "application/octet-stream", + schema = @Schema(type = "string", format = "binary"))), + @ApiResponse(responseCode = "403", description = "Access denied"), + @ApiResponse(responseCode = "404", description = "Attachment not found"), + @ApiResponse(responseCode = "502", description = "Failed to fetch file from storage") + }) + public ResponseEntity download( + @Parameter(description = "ID of the attachment", example = "1") @PathVariable Long id) { - Attachment att = attachmentService.getAttachmentById(id); - - final GetObjectResponse object; - try { - object = storageService.download(att.getS3Key()); - } catch (Exception ex) { - throw new ResponseStatusException( - HttpStatus.BAD_GATEWAY, - "Failed to download attachment content", - ex - ); - } + Attachment att = attachmentService.getAttachmentById(id); - String fileName = att.getFileName() != null ? att.getFileName() : "file"; + final GetObjectResponse object; + try { + object = storageService.download(att.getS3Key()); + } catch (Exception ex) { + throw new ResponseStatusException( + HttpStatus.BAD_GATEWAY, "Failed to download attachment content", ex); + } - String contentDisposition = ContentDisposition.builder("attachment") - .filename(fileName, StandardCharsets.UTF_8) - .build() - .toString(); + String fileName = att.getFileName() != null ? att.getFileName() : "file"; - InputStreamResource resource = new InputStreamResource(object); + String contentDisposition = + ContentDisposition.builder("attachment") + .filename(fileName, StandardCharsets.UTF_8) + .build() + .toString(); - long contentLength = object.headers().get("Content-Length") != null - ? Long.parseLong(object.headers().get("Content-Length")) - : -1; + InputStreamResource resource = new InputStreamResource(object); - ResponseEntity.BodyBuilder builder = ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) - .contentType(MediaType.APPLICATION_OCTET_STREAM); + long contentLength = + object.headers().get("Content-Length") != null + ? Long.parseLong(object.headers().get("Content-Length")) + : -1; - if (contentLength > 0) { - builder.contentLength(contentLength); - } + ResponseEntity.BodyBuilder builder = + ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) + .contentType(MediaType.APPLICATION_OCTET_STREAM); - return builder.body(resource); + if (contentLength > 0) { + builder.contentLength(contentLength); } -} \ No newline at end of file + + return builder.body(resource); + } +} diff --git a/src/main/java/org/example/alfs/controllers/AuthController.java b/src/main/java/org/example/alfs/controllers/AuthController.java index 934e383..585e554 100644 --- a/src/main/java/org/example/alfs/controllers/AuthController.java +++ b/src/main/java/org/example/alfs/controllers/AuthController.java @@ -5,8 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import org.example.alfs.dto.auth.LoginRequestDTO; import org.example.alfs.dto.auth.LoginResponseDTO; import org.example.alfs.dto.auth.SignupRequestDTO; @@ -14,89 +13,78 @@ import org.example.alfs.security.JwtService; import org.example.alfs.services.AuthService; import org.springframework.web.bind.annotation.*; -import jakarta.validation.Valid; - /** * REST-based authentication controller used for API clients such as Postman. * - * This controller handles JSON-based authentication requests and returns JWT tokens. + *

This controller handles JSON-based authentication requests and returns JWT tokens. * - * NOTE: - * The application also contains a separate AuthViewController which handles - * browser-based login using HTML forms and cookies. + *

NOTE: The application also contains a separate AuthViewController which handles browser-based + * login using HTML forms and cookies. * - * We intentionally separate these concerns: + *

We intentionally separate these concerns: * - * - AuthController → API (JSON, used for testing and potential future clients) - * - AuthViewController → UI (HTML forms, browser login flow) + *

- AuthController → API (JSON, used for testing and potential future clients) - + * AuthViewController → UI (HTML forms, browser login flow) * - * This separation keeps the API clean and allows independent development - * of backend logic and user interface. + *

This separation keeps the API clean and allows independent development of backend logic and + * user interface. */ @RestController @RequestMapping("/auth") public class AuthController { - private final AuthService authService; - private final JwtService jwtService; - - public AuthController(AuthService authService, JwtService jwtService) { - this.authService = authService; - this.jwtService = jwtService; - } - - /** - * Authenticates a user using JSON input and returns a JWT. - * - * This endpoint is mainly used for API testing (e.g. Postman). - * For browser-based login, see AuthViewController. - */ - @Operation( - summary = "Log in a user", - description = "Authenticate with username and password and receive a JWT token" - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Login successful", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = LoginResponseDTO.class) - ) - ), - @ApiResponse(responseCode = "401", description = "Invalid username or password"), - @ApiResponse(responseCode = "400", description = "Invalid request body") - }) - @PostMapping("/login") - public LoginResponseDTO login(@Valid @RequestBody LoginRequestDTO request) { + private final AuthService authService; + private final JwtService jwtService; - User user = authService.login( - request.getUsername(), - request.getPassword() - ); + public AuthController(AuthService authService, JwtService jwtService) { + this.authService = authService; + this.jwtService = jwtService; + } - String token = jwtService.generateToken(user); + /** + * Authenticates a user using JSON input and returns a JWT. + * + *

This endpoint is mainly used for API testing (e.g. Postman). For browser-based login, see + * AuthViewController. + */ + @Operation( + summary = "Log in a user", + description = "Authenticate with username and password and receive a JWT token") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Login successful", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = LoginResponseDTO.class))), + @ApiResponse(responseCode = "401", description = "Invalid username or password"), + @ApiResponse(responseCode = "400", description = "Invalid request body") + }) + @PostMapping("/login") + public LoginResponseDTO login(@Valid @RequestBody LoginRequestDTO request) { - return new LoginResponseDTO(token); - } + User user = authService.login(request.getUsername(), request.getPassword()); + String token = jwtService.generateToken(user); - /** - * Handles user signup by validating input and creating a new account. - */ - @Operation( - summary = "Sign up new user", - description = "Create a new user account with username and password" - ) - @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "User created successfully"), - @ApiResponse(responseCode = "400", description = "Invalid input"), - @ApiResponse(responseCode = "409", description = "Username already exists") - }) - @PostMapping("/signup") - public void signup(@Valid @RequestBody SignupRequestDTO request) { - authService.signup(request); - } + return new LoginResponseDTO(token); + } + /** Handles user signup by validating input and creating a new account. */ + @Operation( + summary = "Sign up new user", + description = "Create a new user account with username and password") + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "User created successfully"), + @ApiResponse(responseCode = "400", description = "Invalid input"), + @ApiResponse(responseCode = "409", description = "Username already exists") + }) + @PostMapping("/signup") + public void signup(@Valid @RequestBody SignupRequestDTO request) { + authService.signup(request); + } } diff --git a/src/main/java/org/example/alfs/controllers/AuthViewController.java b/src/main/java/org/example/alfs/controllers/AuthViewController.java index 5713b82..b828cf3 100644 --- a/src/main/java/org/example/alfs/controllers/AuthViewController.java +++ b/src/main/java/org/example/alfs/controllers/AuthViewController.java @@ -1,8 +1,8 @@ package org.example.alfs.controllers; -import org.springframework.web.servlet.mvc.support.RedirectAttributes; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +import java.time.Duration; import org.example.alfs.dto.auth.SignupRequestDTO; import org.example.alfs.entities.User; import org.example.alfs.security.JwtService; @@ -14,103 +14,102 @@ import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; - -import java.time.Duration; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; /** * Handles login for the browser (UI). * - * Uses HTML forms and stores the JWT in a cookie so the user stays logged in. + *

Uses HTML forms and stores the JWT in a cookie so the user stays logged in. * - * Separate from AuthController, which is used for API login via JSON. + *

Separate from AuthController, which is used for API login via JSON. */ @Controller public class AuthViewController { - private final AuthService authService; - private final JwtService jwtService; - - public AuthViewController(AuthService authService, JwtService jwtService) { - this.authService = authService; - this.jwtService = jwtService; - } - - @GetMapping("/login") - public String loginPage(@RequestParam(required = false) String error, @RequestParam(required = false) String tokenError, Model model) { - model.addAttribute("error", error); - model.addAttribute("tokenError", tokenError); - return "login"; - } - - @GetMapping("/signup") - public String signupPage() { - return "signup"; + private final AuthService authService; + private final JwtService jwtService; + + public AuthViewController(AuthService authService, JwtService jwtService) { + this.authService = authService; + this.jwtService = jwtService; + } + + @GetMapping("/login") + public String loginPage( + @RequestParam(required = false) String error, + @RequestParam(required = false) String tokenError, + Model model) { + model.addAttribute("error", error); + model.addAttribute("tokenError", tokenError); + return "login"; + } + + @GetMapping("/signup") + public String signupPage() { + return "signup"; + } + + @PostMapping("/signup-form") + public String signupForm( + @Valid @ModelAttribute SignupRequestDTO request, BindingResult bindingResult) { + if (bindingResult.hasErrors()) { + return "signup"; } - @PostMapping("/signup-form") - public String signupForm( - @Valid @ModelAttribute SignupRequestDTO request, - BindingResult bindingResult - ) { - if (bindingResult.hasErrors()) { - return "signup"; - } - - authService.signup(request); - - return "redirect:/login"; + authService.signup(request); + return "redirect:/login"; + } - } - - @PostMapping("/login-form") - public String loginForm( - @RequestParam String username, - @RequestParam String password, - HttpServletResponse response, - RedirectAttributes redirectAttributes - ) { - try { - User user = authService.login(username, password); + @PostMapping("/login-form") + public String loginForm( + @RequestParam String username, + @RequestParam String password, + HttpServletResponse response, + RedirectAttributes redirectAttributes) { + try { + User user = authService.login(username, password); - String token = jwtService.generateToken(user); + String token = jwtService.generateToken(user); - ResponseCookie cookie = ResponseCookie.from("JWT", token) - .httpOnly(true) - .path("/") - .maxAge(Duration.ofDays(1)) - .sameSite("Lax") - .build(); + ResponseCookie cookie = + ResponseCookie.from("JWT", token) + .httpOnly(true) + .path("/") + .maxAge(Duration.ofDays(1)) + .sameSite("Lax") + .build(); - response.addHeader("Set-Cookie", cookie.toString()); + response.addHeader("Set-Cookie", cookie.toString()); - redirectAttributes.addFlashAttribute("success", "You are signed in!"); - return "redirect:/"; - - } catch (ResponseStatusException ex) { - - // login-error -> redirect to form and show error - if (ex.getStatusCode() == HttpStatus.UNAUTHORIZED) { - return "redirect:/login?error=true"; - } - - // other error throw - throw ex; - } - } + redirectAttributes.addFlashAttribute("success", "You are signed in!"); + return "redirect:/"; - @PostMapping("/auth/logout") - public String logout(HttpServletResponse response, RedirectAttributes redirectAttributes) { + } catch (ResponseStatusException ex) { - ResponseCookie cookie = ResponseCookie.from("JWT", "") - .httpOnly(true) - .path("/") - .maxAge(Duration.ZERO) - .sameSite("Lax") - .build(); + // login-error -> redirect to form and show error + if (ex.getStatusCode() == HttpStatus.UNAUTHORIZED) { + return "redirect:/login?error=true"; + } - response.addHeader("Set-Cookie", cookie.toString()); - redirectAttributes.addFlashAttribute("success", "Successfully signed out"); - return "redirect:/"; + // other error throw + throw ex; } -} \ No newline at end of file + } + + @PostMapping("/auth/logout") + public String logout(HttpServletResponse response, RedirectAttributes redirectAttributes) { + + ResponseCookie cookie = + ResponseCookie.from("JWT", "") + .httpOnly(true) + .path("/") + .maxAge(Duration.ZERO) + .sameSite("Lax") + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + redirectAttributes.addFlashAttribute("success", "Successfully signed out"); + return "redirect:/"; + } +} diff --git a/src/main/java/org/example/alfs/controllers/ErrorController.java b/src/main/java/org/example/alfs/controllers/ErrorController.java index d78b9c4..96aa044 100644 --- a/src/main/java/org/example/alfs/controllers/ErrorController.java +++ b/src/main/java/org/example/alfs/controllers/ErrorController.java @@ -10,19 +10,19 @@ @RequestMapping("/error") public class ErrorController { - @RequestMapping("/403") - @ResponseStatus(HttpStatus.FORBIDDEN) - public String forbidden(Model model) { - model.addAttribute("status", 403); - model.addAttribute("error", "Access denied"); - return "error"; - } + @RequestMapping("/403") + @ResponseStatus(HttpStatus.FORBIDDEN) + public String forbidden(Model model) { + model.addAttribute("status", 403); + model.addAttribute("error", "Access denied"); + return "error"; + } - @RequestMapping("/401") - @ResponseStatus(HttpStatus.UNAUTHORIZED) - public String unauthorized(Model model) { - model.addAttribute("status", 401); - model.addAttribute("error", "You need to log in to access this page"); - return "error"; - } + @RequestMapping("/401") + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public String unauthorized(Model model) { + model.addAttribute("status", 401); + model.addAttribute("error", "You need to log in to access this page"); + return "error"; + } } diff --git a/src/main/java/org/example/alfs/controllers/StartPageController.java b/src/main/java/org/example/alfs/controllers/StartPageController.java index a41a840..d2fbb77 100644 --- a/src/main/java/org/example/alfs/controllers/StartPageController.java +++ b/src/main/java/org/example/alfs/controllers/StartPageController.java @@ -7,8 +7,8 @@ @Controller public class StartPageController { - @GetMapping("/") - public String startPage(Model model){ - return "startPage"; - } + @GetMapping("/") + public String startPage(Model model) { + return "startPage"; + } } diff --git a/src/main/java/org/example/alfs/controllers/TestController.java b/src/main/java/org/example/alfs/controllers/TestController.java index b7846d7..528157f 100644 --- a/src/main/java/org/example/alfs/controllers/TestController.java +++ b/src/main/java/org/example/alfs/controllers/TestController.java @@ -9,56 +9,54 @@ @RestController public class TestController { - @PreAuthorize("hasAnyRole('ADMIN','INVESTIGATOR','REPORTER')") - @GetMapping("/api/test/all-roles") - public String allRoles() { - return "all roles allowed"; - } - - @PreAuthorize("hasRole('REPORTER')") - @GetMapping("/api/test/create-ticket") - public String createTicket() { - return "reporter can create ticket"; - } - - - @PreAuthorize("hasRole('ADMIN')") - @GetMapping("/api/test/assign-ticket") - public String assignTicket() { - return "admin can assign ticket"; - } - - @PreAuthorize("hasRole('INVESTIGATOR')") - @GetMapping("/api/test/update-status") - public String updateStatus() { - return "investigator can update status"; - } - - // need to be signed in - all roles - @GetMapping("/api/hello") - public String hello() { - return "hello secured"; - } - - // Only ADMIN - @PreAuthorize("hasRole('ADMIN')") - @GetMapping("/api/admin/test") - public String adminOnly() { - return "only admin"; - } - - // Only INVESTIGATOR - @PreAuthorize("hasRole('INVESTIGATOR')") - @GetMapping("/api/investigator/test") - public String investigatorOnly() { - return "only investigator"; - } - - // Only REPORTER - @PreAuthorize("hasRole('REPORTER')") - @GetMapping("/api/reporter/test") - public String reporterOnly() { - return "only reporter"; - } - -} \ No newline at end of file + @PreAuthorize("hasAnyRole('ADMIN','INVESTIGATOR','REPORTER')") + @GetMapping("/api/test/all-roles") + public String allRoles() { + return "all roles allowed"; + } + + @PreAuthorize("hasRole('REPORTER')") + @GetMapping("/api/test/create-ticket") + public String createTicket() { + return "reporter can create ticket"; + } + + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/api/test/assign-ticket") + public String assignTicket() { + return "admin can assign ticket"; + } + + @PreAuthorize("hasRole('INVESTIGATOR')") + @GetMapping("/api/test/update-status") + public String updateStatus() { + return "investigator can update status"; + } + + // need to be signed in - all roles + @GetMapping("/api/hello") + public String hello() { + return "hello secured"; + } + + // Only ADMIN + @PreAuthorize("hasRole('ADMIN')") + @GetMapping("/api/admin/test") + public String adminOnly() { + return "only admin"; + } + + // Only INVESTIGATOR + @PreAuthorize("hasRole('INVESTIGATOR')") + @GetMapping("/api/investigator/test") + public String investigatorOnly() { + return "only investigator"; + } + + // Only REPORTER + @PreAuthorize("hasRole('REPORTER')") + @GetMapping("/api/reporter/test") + public String reporterOnly() { + return "only reporter"; + } +} diff --git a/src/main/java/org/example/alfs/controllers/TicketCommentController.java b/src/main/java/org/example/alfs/controllers/TicketCommentController.java index efd893b..aa1ec2e 100644 --- a/src/main/java/org/example/alfs/controllers/TicketCommentController.java +++ b/src/main/java/org/example/alfs/controllers/TicketCommentController.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; +import java.util.List; import org.example.alfs.dto.comment.CommentCreateDTO; import org.example.alfs.dto.comment.CommentViewDTO; import org.example.alfs.entities.User; @@ -15,86 +16,77 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; - -import java.util.List; - @Controller @RequestMapping("/tickets") public class TicketCommentController { - private final TicketCommentService commentService; - private final SecurityUtils securityUtils; - - public TicketCommentController(TicketCommentService commentService, - SecurityUtils securityUtils) { - this.commentService = commentService; - this.securityUtils = securityUtils; - } - - @PostMapping("/{ticketId}/comments") - public String addComment( - @PathVariable Long ticketId, - @Valid @ModelAttribute CommentCreateDTO dto, - @RequestParam(required = false) String token - ) { - User user = getCurrentUserOrNull(); + private final TicketCommentService commentService; + private final SecurityUtils securityUtils; - commentService.addComment(ticketId, dto, user, token); + public TicketCommentController(TicketCommentService commentService, SecurityUtils securityUtils) { + this.commentService = commentService; + this.securityUtils = securityUtils; + } - if (token != null && !token.isBlank()) { - return "redirect:/tickets/token/" + token; - } + @PostMapping("/{ticketId}/comments") + public String addComment( + @PathVariable Long ticketId, + @Valid @ModelAttribute CommentCreateDTO dto, + @RequestParam(required = false) String token) { + User user = getCurrentUserOrNull(); - return "redirect:/tickets/" + ticketId; - } - - @Operation( - summary = "Get comments for a ticket", - description = "Returns all comments for a ticket. Supports both authenticated users and anonymous users via token." - ) - @ApiResponses(value = { - @ApiResponse( - responseCode = "200", - description = "Comments retrieved successfully", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = CommentViewDTO.class) - ) - ), - @ApiResponse(responseCode = "403", description = "Access denied"), - @ApiResponse(responseCode = "404", description = "Ticket not found") - }) - @GetMapping("/{ticketId}/comments") - @ResponseBody - public List getComments( - - @Parameter(description = "ID of the ticket", example = "1") - @PathVariable Long ticketId, - - @Parameter(description = "Optional token for anonymous access") - @RequestParam(required = false) String token - ) - { - User user = getCurrentUserOrNull(); + commentService.addComment(ticketId, dto, user, token); - return commentService.getComments(ticketId, user, token); + if (token != null && !token.isBlank()) { + return "redirect:/tickets/token/" + token; } - private User getCurrentUserOrNull() { - try { - return securityUtils.getCurrentUser(); - } catch (RuntimeException ex) { - String message = ex.getMessage(); - - boolean authFailure = - "No authenticated user in security context".equals(message) || - "Authenticated user not found in database".equals(message); - - if (authFailure) { - return null; - } - - throw ex; - } + return "redirect:/tickets/" + ticketId; + } + + @Operation( + summary = "Get comments for a ticket", + description = + "Returns all comments for a ticket. Supports both authenticated users and anonymous users via token.") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Comments retrieved successfully", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = CommentViewDTO.class))), + @ApiResponse(responseCode = "403", description = "Access denied"), + @ApiResponse(responseCode = "404", description = "Ticket not found") + }) + @GetMapping("/{ticketId}/comments") + @ResponseBody + public List getComments( + @Parameter(description = "ID of the ticket", example = "1") @PathVariable Long ticketId, + @Parameter(description = "Optional token for anonymous access") + @RequestParam(required = false) + String token) { + User user = getCurrentUserOrNull(); + + return commentService.getComments(ticketId, user, token); + } + + private User getCurrentUserOrNull() { + try { + return securityUtils.getCurrentUser(); + } catch (RuntimeException ex) { + String message = ex.getMessage(); + + boolean authFailure = + "No authenticated user in security context".equals(message) + || "Authenticated user not found in database".equals(message); + + if (authFailure) { + return null; + } + + throw ex; } -} \ No newline at end of file + } +} diff --git a/src/main/java/org/example/alfs/controllers/TicketController.java b/src/main/java/org/example/alfs/controllers/TicketController.java index 561d0aa..8bef5df 100644 --- a/src/main/java/org/example/alfs/controllers/TicketController.java +++ b/src/main/java/org/example/alfs/controllers/TicketController.java @@ -1,187 +1,184 @@ package org.example.alfs.controllers; -import org.example.alfs.security.SecurityUtils; -import org.example.alfs.services.*; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.web.servlet.mvc.support.RedirectAttributes; + import jakarta.validation.Valid; import org.example.alfs.dto.ticket.TicketAssignDTO; import org.example.alfs.dto.ticket.TicketCreateDTO; import org.example.alfs.dto.ticket.TicketStatusUpdateDTO; import org.example.alfs.dto.ticket.TicketViewDTO; +import org.example.alfs.security.SecurityUtils; +import org.example.alfs.services.*; +import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; -//TODO: Decide final routes and redirects @Controller @RequestMapping("/tickets") public class TicketController { - private final TicketService ticketService; - private final TicketCommentService ticketCommentService; - private final AttachmentService attachmentService; - private final AuditService auditService; - private final SecurityUtils securityUtils; - private final UserService userService; - - public TicketController(TicketService ticketService, TicketCommentService ticketCommentService,AttachmentService attachmentService, AuditService auditService, SecurityUtils securityUtils, UserService userService) { - this.ticketService = ticketService; - this.ticketCommentService = ticketCommentService; - this.attachmentService = attachmentService; - this.auditService = auditService; - this.securityUtils = securityUtils; - this.userService = userService; + private final TicketService ticketService; + private final TicketCommentService ticketCommentService; + private final AttachmentService attachmentService; + private final AuditService auditService; + private final SecurityUtils securityUtils; + private final UserService userService; + + public TicketController( + TicketService ticketService, + TicketCommentService ticketCommentService, + AttachmentService attachmentService, + AuditService auditService, + SecurityUtils securityUtils, + UserService userService) { + this.ticketService = ticketService; + this.ticketCommentService = ticketCommentService; + this.attachmentService = attachmentService; + this.auditService = auditService; + this.securityUtils = securityUtils; + this.userService = userService; + } + + // create ticket + // @PreAuthorize("hasRole('REPORTER')") // should change later for anonymous access + @GetMapping("/create") + public String createNewTicketForm(Model model) { + model.addAttribute("ticket", new TicketCreateDTO()); + return "create"; + } + + // @PreAuthorize("hasRole('REPORTER')") // should change later for anonymous access + @PostMapping("/create") + public String createNewTicket( + @ModelAttribute("ticket") @Valid TicketCreateDTO dto, + BindingResult bindingResult, + Model model, + RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + model.addAttribute("ticket", dto); + return "create"; } - //create ticket - //@PreAuthorize("hasRole('REPORTER')") // should change later for anonymous access - @GetMapping("/create") - public String createNewTicketForm(Model model) { - model.addAttribute("ticket", new TicketCreateDTO()); - return "create"; - } + TicketViewDTO ticket = ticketService.createNewTicket(dto); + redirectAttributes.addFlashAttribute("success", "Ticket created successfully"); - //@PreAuthorize("hasRole('REPORTER')") // should change later for anonymous access - @PostMapping("/create") - public String createNewTicket( - @ModelAttribute("ticket") @Valid TicketCreateDTO dto, - BindingResult bindingResult, - Model model, - RedirectAttributes redirectAttributes - ) { - if (bindingResult.hasErrors()) { - model.addAttribute("ticket", dto); - return "create"; - } - - TicketViewDTO ticket = ticketService.createNewTicket(dto); - redirectAttributes.addFlashAttribute("success", "Ticket created successfully"); - - if (ticket.getToken() != null) { - return "redirect:/tickets/ticket-created?token=" + ticket.getToken(); - } - - return "redirect:/tickets/" + ticket.getId(); + if (ticket.getToken() != null) { + return "redirect:/tickets/ticket-created?token=" + ticket.getToken(); } + return "redirect:/tickets/" + ticket.getId(); + } + // view ticket by token + @GetMapping("/token/{token}") + public String viewTicketByToken(@PathVariable String token, Model model) { + try { + TicketViewDTO ticket = ticketService.getTicketByToken(token); - //view ticket by token - @GetMapping("/token/{token}") - public String viewTicketByToken(@PathVariable String token, Model model) { - - try { - TicketViewDTO ticket = ticketService.getTicketByToken(token); + var user = securityUtils.getCurrentUserOrNull(); - var user = securityUtils.getCurrentUserOrNull(); + var comments = ticketCommentService.getComments(ticket.getId(), user, token); + var attachments = attachmentService.getAttachmentsByTicketId(ticket.getId()); + var auditLogs = auditService.getAuditLogsForTicket(ticket.getId()); + var investigators = userService.getAllInvestigators(); - var comments = ticketCommentService.getComments(ticket.getId(), user, token); - var attachments = attachmentService.getAttachmentsByTicketId(ticket.getId()); - var auditLogs = auditService.getAuditLogsForTicket(ticket.getId()); - var investigators = userService.getAllInvestigators(); + model.addAttribute("ticket", ticket); + model.addAttribute("comments", comments); + model.addAttribute("attachments", attachments); + model.addAttribute("auditLogs", auditLogs); + model.addAttribute("investigators", investigators); + model.addAttribute("accessToken", token); - model.addAttribute("ticket", ticket); - model.addAttribute("comments", comments); - model.addAttribute("attachments", attachments); - model.addAttribute("auditLogs", auditLogs); - model.addAttribute("investigators", investigators); - model.addAttribute("accessToken", token); + return "view"; - return "view"; + } catch (ResponseStatusException ex) { - } catch (ResponseStatusException ex) { + if (ex.getStatusCode() == HttpStatus.NOT_FOUND) { + return "redirect:/login?tokenError=true"; + } - if (ex.getStatusCode() == HttpStatus.NOT_FOUND) { - return "redirect:/login?tokenError=true"; - } - - throw ex; - } + throw ex; } + } + // view ticket by id + @PreAuthorize("hasAnyRole('ADMIN','INVESTIGATOR','REPORTER')") + @GetMapping("/{id}") + public String viewTicketById(@PathVariable Long id, Model model) { - //view ticket by id - @PreAuthorize("hasAnyRole('ADMIN','INVESTIGATOR','REPORTER')") - @GetMapping("/{id}") - public String viewTicketById(@PathVariable Long id, Model model) { + TicketViewDTO ticket = ticketService.getTicketById(id); - TicketViewDTO ticket = ticketService.getTicketById(id); + var user = securityUtils.getCurrentUserOrNull(); - var user = securityUtils.getCurrentUserOrNull(); + // get comments + var comments = ticketCommentService.getComments(id, user, null); - // get comments - var comments = ticketCommentService.getComments(id, user, null); + // get attachments + var attachments = attachmentService.getAttachmentsByTicketId(id); - // get attachments - var attachments = attachmentService.getAttachmentsByTicketId(id); + // get audit logs + var auditLogs = auditService.getAuditLogsForTicket(id); - // get audit logs - var auditLogs = auditService.getAuditLogsForTicket(id); + var investigators = userService.getAllInvestigators(); - var investigators = userService.getAllInvestigators(); + model.addAttribute("ticket", ticket); + model.addAttribute("comments", comments); + model.addAttribute("attachments", attachments); + model.addAttribute("auditLogs", auditLogs); + model.addAttribute("investigators", investigators); + model.addAttribute("accessToken", null); + return "view"; + } - model.addAttribute("ticket", ticket); - model.addAttribute("comments", comments); - model.addAttribute("attachments", attachments); - model.addAttribute("auditLogs", auditLogs); - model.addAttribute("investigators", investigators); - model.addAttribute("accessToken", null); + // assign ticket + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/{id}/assign") + public String assignTicket(@PathVariable Long id, @Valid @ModelAttribute TicketAssignDTO dto) { + ticketService.assignInvestigator(id, dto.getInvestigatorId()); - return "view"; - } + return "redirect:/tickets/" + id; + } + // update status + @PostMapping("/{id}/status") + @PreAuthorize("hasAnyRole('ADMIN','INVESTIGATOR')") + public String updateStatus( + @PathVariable Long id, @Valid @ModelAttribute TicketStatusUpdateDTO dto) { - //assign ticket - @PreAuthorize("hasRole('ADMIN')") - @PostMapping("/{id}/assign") - public String assignTicket(@PathVariable Long id, @Valid @ModelAttribute TicketAssignDTO dto) { + ticketService.updateTicketStatus(id, dto.getStatus()); - ticketService.assignInvestigator(id, dto.getInvestigatorId()); + return "redirect:/tickets/" + id; + } - return "redirect:/tickets/" + id; - } + @PreAuthorize("hasRole('REPORTER')") + @GetMapping("/my") + public String myTickets(Model model) { - //update status - @PostMapping("/{id}/status") - @PreAuthorize("hasAnyRole('ADMIN','INVESTIGATOR')") - public String updateStatus(@PathVariable Long id, @Valid @ModelAttribute TicketStatusUpdateDTO dto) { + model.addAttribute("tickets", ticketService.getMyTickets()); + return "my-tickets"; + } - ticketService.updateTicketStatus(id, dto.getStatus()); + @PreAuthorize("hasRole('INVESTIGATOR')") + @GetMapping("/assigned") + public String myAssignedTickets(Model model) { - return "redirect:/tickets/" + id; - } + model.addAttribute("tickets", ticketService.getMyAssignedTickets()); + return "assigned-tickets"; + } - - @PreAuthorize("hasRole('REPORTER')") - @GetMapping("/my") - public String myTickets(Model model) { - - model.addAttribute("tickets", ticketService.getMyTickets()); - return "my-tickets"; - } - - @PreAuthorize("hasRole('INVESTIGATOR')") - @GetMapping("/assigned") - public String myAssignedTickets(Model model) { - - model.addAttribute("tickets", ticketService.getMyAssignedTickets()); - return "assigned-tickets"; - } - - - @GetMapping("/ticket-created") - public String ticketCreated(@RequestParam String token, Model model) { - model.addAttribute("token", token); - return "ticket-created"; - } - //create comment - //View comment - //upload attachment + @GetMapping("/ticket-created") + public String ticketCreated(@RequestParam String token, Model model) { + model.addAttribute("token", token); + return "ticket-created"; + } + // create comment + // View comment + // upload attachment } diff --git a/src/main/java/org/example/alfs/dto/attachment/AttachmentViewDTO.java b/src/main/java/org/example/alfs/dto/attachment/AttachmentViewDTO.java index 04c21ca..1d8b9af 100644 --- a/src/main/java/org/example/alfs/dto/attachment/AttachmentViewDTO.java +++ b/src/main/java/org/example/alfs/dto/attachment/AttachmentViewDTO.java @@ -3,64 +3,75 @@ import java.time.LocalDateTime; public class AttachmentViewDTO { - private Long id; - private Long ticketId; - private String fileName; - private String downloadUrl; - private LocalDateTime uploadedAt; - private String uploadedBy; - - public AttachmentViewDTO() {} - - public AttachmentViewDTO(Long id, Long ticketId, String fileName, String downloadUrl, LocalDateTime uploadedAt, String uploadedBy) { - this.id = id; - this.ticketId = ticketId; - this.fileName = fileName; - this.downloadUrl = downloadUrl; - this.uploadedAt = uploadedAt; - this.uploadedBy = uploadedBy; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getTicketId() { - return ticketId; - } - - public void setTicketId(Long ticketId) { - this.ticketId = ticketId; - } - - public String getFileName() { - return fileName; - } - - public void setFileName(String fileName) { - this.fileName = fileName; - } - - public String getDownloadUrl() { - return downloadUrl; - } - - public void setDownloadUrl(String downloadUrl) { - this.downloadUrl = downloadUrl; - } - - public LocalDateTime getUploadedAt() { - return uploadedAt; - } - - public void setUploadedAt(LocalDateTime uploadedAt) { - this.uploadedAt = uploadedAt; - } - - public String getUploadedBy() { return uploadedBy; } - public void setUploadedBy(String uploadedBy) { this.uploadedBy = uploadedBy; } + private Long id; + private Long ticketId; + private String fileName; + private String downloadUrl; + private LocalDateTime uploadedAt; + private String uploadedBy; + + public AttachmentViewDTO() {} + + public AttachmentViewDTO( + Long id, + Long ticketId, + String fileName, + String downloadUrl, + LocalDateTime uploadedAt, + String uploadedBy) { + this.id = id; + this.ticketId = ticketId; + this.fileName = fileName; + this.downloadUrl = downloadUrl; + this.uploadedAt = uploadedAt; + this.uploadedBy = uploadedBy; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getTicketId() { + return ticketId; + } + + public void setTicketId(Long ticketId) { + this.ticketId = ticketId; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getDownloadUrl() { + return downloadUrl; + } + + public void setDownloadUrl(String downloadUrl) { + this.downloadUrl = downloadUrl; + } + + public LocalDateTime getUploadedAt() { + return uploadedAt; + } + + public void setUploadedAt(LocalDateTime uploadedAt) { + this.uploadedAt = uploadedAt; + } + + public String getUploadedBy() { + return uploadedBy; + } + + public void setUploadedBy(String uploadedBy) { + this.uploadedBy = uploadedBy; + } } diff --git a/src/main/java/org/example/alfs/dto/auth/LoginRequestDTO.java b/src/main/java/org/example/alfs/dto/auth/LoginRequestDTO.java index 522615b..d1b23f1 100644 --- a/src/main/java/org/example/alfs/dto/auth/LoginRequestDTO.java +++ b/src/main/java/org/example/alfs/dto/auth/LoginRequestDTO.java @@ -10,12 +10,17 @@ @Schema(description = "Credentials required to authenticate user") public class LoginRequestDTO { - @NotBlank(message = "Username is required") - @Schema(description = "Registered username", example = "cool_username", requiredMode = Schema.RequiredMode.REQUIRED) - private String username; - - @NotBlank(message = "Password is required") - @Schema(description = "The users secret password", example = "itsAsecret123", requiredMode = Schema.RequiredMode.REQUIRED) - private String password; + @NotBlank(message = "Username is required") + @Schema( + description = "Registered username", + example = "cool_username", + requiredMode = Schema.RequiredMode.REQUIRED) + private String username; + @NotBlank(message = "Password is required") + @Schema( + description = "The users secret password", + example = "itsAsecret123", + requiredMode = Schema.RequiredMode.REQUIRED) + private String password; } diff --git a/src/main/java/org/example/alfs/dto/auth/LoginResponseDTO.java b/src/main/java/org/example/alfs/dto/auth/LoginResponseDTO.java index f2d2dc5..c527635 100644 --- a/src/main/java/org/example/alfs/dto/auth/LoginResponseDTO.java +++ b/src/main/java/org/example/alfs/dto/auth/LoginResponseDTO.java @@ -7,5 +7,5 @@ @AllArgsConstructor public class LoginResponseDTO { - private String token; + private String token; } diff --git a/src/main/java/org/example/alfs/dto/auth/SignupRequestDTO.java b/src/main/java/org/example/alfs/dto/auth/SignupRequestDTO.java index ada5550..c8bbba2 100644 --- a/src/main/java/org/example/alfs/dto/auth/SignupRequestDTO.java +++ b/src/main/java/org/example/alfs/dto/auth/SignupRequestDTO.java @@ -5,18 +5,22 @@ import lombok.Getter; import lombok.Setter; - @Getter @Setter @Schema(description = "Credentials required to sign up new user") public class SignupRequestDTO { - @NotBlank(message = "Username is required") - @Schema(description = "New username", example = "cool_username", requiredMode = Schema.RequiredMode.REQUIRED) - private String username; - - @NotBlank(message = "Password is required") - @Schema(description = "New password", example = "itsAsecret123", requiredMode = Schema.RequiredMode.REQUIRED) - private String password; + @NotBlank(message = "Username is required") + @Schema( + description = "New username", + example = "cool_username", + requiredMode = Schema.RequiredMode.REQUIRED) + private String username; + @NotBlank(message = "Password is required") + @Schema( + description = "New password", + example = "itsAsecret123", + requiredMode = Schema.RequiredMode.REQUIRED) + private String password; } diff --git a/src/main/java/org/example/alfs/dto/comment/CommentCreateDTO.java b/src/main/java/org/example/alfs/dto/comment/CommentCreateDTO.java index 8b2ed19..6322230 100644 --- a/src/main/java/org/example/alfs/dto/comment/CommentCreateDTO.java +++ b/src/main/java/org/example/alfs/dto/comment/CommentCreateDTO.java @@ -10,9 +10,7 @@ @Data public class CommentCreateDTO { - @NotBlank - private String message; + @NotBlank private String message; - private boolean internalNote = false; - -} \ No newline at end of file + private boolean internalNote = false; +} diff --git a/src/main/java/org/example/alfs/dto/comment/CommentViewDTO.java b/src/main/java/org/example/alfs/dto/comment/CommentViewDTO.java index 2b063db..f729978 100644 --- a/src/main/java/org/example/alfs/dto/comment/CommentViewDTO.java +++ b/src/main/java/org/example/alfs/dto/comment/CommentViewDTO.java @@ -1,8 +1,7 @@ package org.example.alfs.dto.comment; -import lombok.Data; - import java.time.LocalDateTime; +import lombok.Data; /* * DTO returned when retrieving comments for a ticket. @@ -17,19 +16,16 @@ @Data public class CommentViewDTO { - private Long id; - private String message; - private String author; - private String role; - private LocalDateTime createdAt; - - private boolean internalNote; + private Long id; + private String message; + private String author; + private String role; + private LocalDateTime createdAt; + private boolean internalNote; - public String getFormattedCreatedAt() { - if (createdAt == null) return ""; - return createdAt.format( - java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm") - ); - } + public String getFormattedCreatedAt() { + if (createdAt == null) return ""; + return createdAt.format(java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm")); + } } diff --git a/src/main/java/org/example/alfs/dto/ticket/TicketAssignDTO.java b/src/main/java/org/example/alfs/dto/ticket/TicketAssignDTO.java index 037089b..ba732f1 100644 --- a/src/main/java/org/example/alfs/dto/ticket/TicketAssignDTO.java +++ b/src/main/java/org/example/alfs/dto/ticket/TicketAssignDTO.java @@ -9,6 +9,5 @@ @Data public class TicketAssignDTO { - @NotNull - private Long investigatorId; + @NotNull private Long investigatorId; } diff --git a/src/main/java/org/example/alfs/dto/ticket/TicketCreateDTO.java b/src/main/java/org/example/alfs/dto/ticket/TicketCreateDTO.java index 568f0ed..457cbeb 100644 --- a/src/main/java/org/example/alfs/dto/ticket/TicketCreateDTO.java +++ b/src/main/java/org/example/alfs/dto/ticket/TicketCreateDTO.java @@ -14,10 +14,7 @@ @Data public class TicketCreateDTO { + @NotBlank private String title; - @NotBlank - private String title; - - @NotBlank - private String description; + @NotBlank private String description; } diff --git a/src/main/java/org/example/alfs/dto/ticket/TicketStatusUpdateDTO.java b/src/main/java/org/example/alfs/dto/ticket/TicketStatusUpdateDTO.java index c75cfd9..45df824 100644 --- a/src/main/java/org/example/alfs/dto/ticket/TicketStatusUpdateDTO.java +++ b/src/main/java/org/example/alfs/dto/ticket/TicketStatusUpdateDTO.java @@ -5,15 +5,12 @@ import org.example.alfs.enums.TicketStatus; /* -*Investigator changes tickets status -*The status represents the lifecycle of the ticket -* (e.g. OPEN, IN_PROGRESS, RESOLVED, CLOSED). -*/ + *Investigator changes tickets status + *The status represents the lifecycle of the ticket + * (e.g. OPEN, IN_PROGRESS, RESOLVED, CLOSED). + */ @Data public class TicketStatusUpdateDTO { - @NotNull - private TicketStatus status; - - + @NotNull private TicketStatus status; } diff --git a/src/main/java/org/example/alfs/dto/ticket/TicketViewDTO.java b/src/main/java/org/example/alfs/dto/ticket/TicketViewDTO.java index 4126e34..1fef218 100644 --- a/src/main/java/org/example/alfs/dto/ticket/TicketViewDTO.java +++ b/src/main/java/org/example/alfs/dto/ticket/TicketViewDTO.java @@ -1,10 +1,10 @@ package org.example.alfs.dto.ticket; -import lombok.Data; -import org.example.alfs.enums.TicketStatus; -import java.time.format.DateTimeFormatter; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.Locale; +import lombok.Data; +import org.example.alfs.enums.TicketStatus; /* * DTO returned when retrieving ticket information. @@ -17,22 +17,22 @@ @Data public class TicketViewDTO { - private Long id; - private String token; - private String title; - private String description; - private TicketStatus status; - private LocalDateTime createdAt; + private Long id; + private String token; + private String title; + private String description; + private TicketStatus status; + private LocalDateTime createdAt; - private Long assignedInvestigatorId; - private String assignedInvestigatorName; + private Long assignedInvestigatorId; + private String assignedInvestigatorName; - private static final DateTimeFormatter DISPLAY_FORMATTER = - DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm", Locale.ENGLISH); + private static final DateTimeFormatter DISPLAY_FORMATTER = + DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm", Locale.ENGLISH); - public String getFormattedCreatedAt() { - if (createdAt == null) return ""; + public String getFormattedCreatedAt() { + if (createdAt == null) return ""; - return createdAt.format(DISPLAY_FORMATTER); - } + return createdAt.format(DISPLAY_FORMATTER); + } } diff --git a/src/main/java/org/example/alfs/entities/Attachment.java b/src/main/java/org/example/alfs/entities/Attachment.java index c4c13e6..94cdcd1 100644 --- a/src/main/java/org/example/alfs/entities/Attachment.java +++ b/src/main/java/org/example/alfs/entities/Attachment.java @@ -1,9 +1,8 @@ package org.example.alfs.entities; import jakarta.persistence.*; -import lombok.*; - import java.time.LocalDateTime; +import lombok.*; /* Represent a file uploaded with Ticket. @@ -17,28 +16,28 @@ @NoArgsConstructor public class Attachment { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false) - private String fileName; + @Column(nullable = false) + private String fileName; - @Column(nullable = false) - private String s3Key; + @Column(nullable = false) + private String s3Key; - private LocalDateTime uploadedAt; + private LocalDateTime uploadedAt; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "uploaded_by") - private User uploadedBy; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "uploaded_by") + private User uploadedBy; - @PrePersist - public void prePersist() { - uploadedAt = LocalDateTime.now(); - } + @PrePersist + public void prePersist() { + uploadedAt = LocalDateTime.now(); + } - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "ticket_id", nullable = false) - private Ticket ticket; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id", nullable = false) + private Ticket ticket; } diff --git a/src/main/java/org/example/alfs/entities/AuditLog.java b/src/main/java/org/example/alfs/entities/AuditLog.java index 9d00090..e8e8c90 100644 --- a/src/main/java/org/example/alfs/entities/AuditLog.java +++ b/src/main/java/org/example/alfs/entities/AuditLog.java @@ -1,11 +1,10 @@ package org.example.alfs.entities; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.*; import org.example.alfs.enums.AuditAction; -import java.time.LocalDateTime; - /* Represents audit log for Ticket. Logs all events such as status change, assignment and comments. @@ -19,43 +18,41 @@ @AllArgsConstructor public class AuditLog { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 50) - private AuditAction action; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private AuditAction action; - @Column(nullable = false) - private String fieldName; + @Column(nullable = false) + private String fieldName; - @Column(length = 4000) - private String oldValue; + @Column(length = 4000) + private String oldValue; - @Column(length = 4000) - private String newValue; + @Column(length = 4000) + private String newValue; - @Column(nullable = false) - private LocalDateTime createdAt; + @Column(nullable = false) + private LocalDateTime createdAt; - @PrePersist - public void prePersist() { - createdAt = LocalDateTime.now(); - } + @PrePersist + public void prePersist() { + createdAt = LocalDateTime.now(); + } - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "ticket_id") - private Ticket ticket; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id") + private Ticket ticket; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - public String getFormattedCreatedAt() { - if (createdAt == null) return ""; - return createdAt.format( - java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm") - ); - } + public String getFormattedCreatedAt() { + if (createdAt == null) return ""; + return createdAt.format(java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm")); + } } diff --git a/src/main/java/org/example/alfs/entities/Ticket.java b/src/main/java/org/example/alfs/entities/Ticket.java index 6db2207..29c4c25 100644 --- a/src/main/java/org/example/alfs/entities/Ticket.java +++ b/src/main/java/org/example/alfs/entities/Ticket.java @@ -1,12 +1,10 @@ package org.example.alfs.entities; import jakarta.persistence.*; -import lombok.*; -import org.example.alfs.enums.TicketStatus; - import java.time.LocalDateTime; import java.util.List; -import java.util.UUID; +import lombok.*; +import org.example.alfs.enums.TicketStatus; /* Representing a whistleblower report. @@ -21,53 +19,53 @@ @NoArgsConstructor public class Ticket { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, length = 255, updatable = false) - private String title; + @Column(nullable = false, length = 255, updatable = false) + private String title; - @Basic(fetch = FetchType.LAZY) - @Column(nullable = false, columnDefinition = "TEXT", updatable = false) - private String description; + @Basic(fetch = FetchType.LAZY) + @Column(nullable = false, columnDefinition = "TEXT", updatable = false) + private String description; - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) - private TicketStatus status; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private TicketStatus status; - @Column(nullable = true, unique = true, length = 128, updatable = false) - private String reporterToken; + @Column(nullable = true, unique = true, length = 128, updatable = false) + private String reporterToken; - private LocalDateTime createdAt; + private LocalDateTime createdAt; - private LocalDateTime updatedAt; + private LocalDateTime updatedAt; - @PrePersist - public void prePersist() { - createdAt = LocalDateTime.now(); - if (status == null) status = TicketStatus.OPEN; - } + @PrePersist + public void prePersist() { + createdAt = LocalDateTime.now(); + if (status == null) status = TicketStatus.OPEN; + } - @PreUpdate - public void preUpdate() { - updatedAt = LocalDateTime.now(); - } + @PreUpdate + public void preUpdate() { + updatedAt = LocalDateTime.now(); + } - @OneToMany(mappedBy = "ticket") - private List comments; + @OneToMany(mappedBy = "ticket") + private List comments; - @OneToMany(mappedBy = "ticket") - private List attachments; + @OneToMany(mappedBy = "ticket") + private List attachments; - @OneToMany(mappedBy = "ticket") - private List auditLogs; + @OneToMany(mappedBy = "ticket") + private List auditLogs; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "reporter_id", nullable = true) // null if anonymous - private User reporter; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = true) // null if anonymous + private User reporter; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "investigator_id", nullable = true) - private User investigator; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "investigator_id", nullable = true) + private User investigator; } diff --git a/src/main/java/org/example/alfs/entities/TicketComment.java b/src/main/java/org/example/alfs/entities/TicketComment.java index 48e3727..79cb0a2 100644 --- a/src/main/java/org/example/alfs/entities/TicketComment.java +++ b/src/main/java/org/example/alfs/entities/TicketComment.java @@ -1,10 +1,8 @@ package org.example.alfs.entities; import jakarta.persistence.*; -import lombok.*; - - import java.time.LocalDateTime; +import lombok.*; /* Represents comment on a ticket. @@ -18,30 +16,30 @@ @NoArgsConstructor public class TicketComment { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Basic(fetch = FetchType.LAZY) - @Column(nullable = false, columnDefinition = "TEXT") - private String message; + @Basic(fetch = FetchType.LAZY) + @Column(nullable = false, columnDefinition = "TEXT") + private String message; - // Möjlighet att skriva internt meddelande (synligt för utredare/admin, inte för anmälare) - @Column(nullable = false) - private boolean internalNote = false; + // Möjlighet att skriva internt meddelande (synligt för utredare/admin, inte för anmälare) + @Column(nullable = false) + private boolean internalNote = false; - private LocalDateTime createdAt; + private LocalDateTime createdAt; - @PrePersist - public void prePersist() { - createdAt = LocalDateTime.now(); - } + @PrePersist + public void prePersist() { + createdAt = LocalDateTime.now(); + } - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "ticket_id", nullable = false) - private Ticket ticket; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id", nullable = false) + private Ticket ticket; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "author_id", nullable = true) - private User author; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = true) + private User author; } diff --git a/src/main/java/org/example/alfs/entities/User.java b/src/main/java/org/example/alfs/entities/User.java index 0319601..136af2c 100644 --- a/src/main/java/org/example/alfs/entities/User.java +++ b/src/main/java/org/example/alfs/entities/User.java @@ -16,25 +16,24 @@ @NoArgsConstructor public class User { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, unique = true, length = 100) - private String username; + @Column(nullable = false, unique = true, length = 100) + private String username; - @Column(nullable = false, length = 255) - private String passwordHash; + @Column(nullable = false, length = 255) + private String passwordHash; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private Role role; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; - @PrePersist - public void prePersist() { - if (role == null) { - role = Role.REPORTER; - } + @PrePersist + public void prePersist() { + if (role == null) { + role = Role.REPORTER; } - + } } diff --git a/src/main/java/org/example/alfs/enums/AuditAction.java b/src/main/java/org/example/alfs/enums/AuditAction.java index 44f2fd3..8d4c7a4 100644 --- a/src/main/java/org/example/alfs/enums/AuditAction.java +++ b/src/main/java/org/example/alfs/enums/AuditAction.java @@ -1,10 +1,10 @@ package org.example.alfs.enums; public enum AuditAction { - CREATED, - STATUS_CHANGED, - ASSIGNED, - UNASSIGNED, - COMMENT_ADDED, - ATTACHMENT_ADDED -} \ No newline at end of file + CREATED, + STATUS_CHANGED, + ASSIGNED, + UNASSIGNED, + COMMENT_ADDED, + ATTACHMENT_ADDED +} diff --git a/src/main/java/org/example/alfs/enums/Role.java b/src/main/java/org/example/alfs/enums/Role.java index 3024fa1..090fd64 100644 --- a/src/main/java/org/example/alfs/enums/Role.java +++ b/src/main/java/org/example/alfs/enums/Role.java @@ -1,7 +1,7 @@ package org.example.alfs.enums; public enum Role { - REPORTER, - INVESTIGATOR, - ADMIN -} \ No newline at end of file + REPORTER, + INVESTIGATOR, + ADMIN +} diff --git a/src/main/java/org/example/alfs/enums/TicketStatus.java b/src/main/java/org/example/alfs/enums/TicketStatus.java index 6442277..2179095 100644 --- a/src/main/java/org/example/alfs/enums/TicketStatus.java +++ b/src/main/java/org/example/alfs/enums/TicketStatus.java @@ -1,8 +1,8 @@ package org.example.alfs.enums; public enum TicketStatus { - OPEN, - IN_PROGRESS, - RESOLVED, - CLOSED + OPEN, + IN_PROGRESS, + RESOLVED, + CLOSED } diff --git a/src/main/java/org/example/alfs/exceptions/GlobalExceptionHandler.java b/src/main/java/org/example/alfs/exceptions/GlobalExceptionHandler.java index 9a92124..9a8e5d5 100644 --- a/src/main/java/org/example/alfs/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/org/example/alfs/exceptions/GlobalExceptionHandler.java @@ -9,34 +9,27 @@ @ControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(ResponseStatusException.class) - public String handleResponseStatusException( - ResponseStatusException ex, - Model model, - HttpServletResponse response - ) { + @ExceptionHandler(ResponseStatusException.class) + public String handleResponseStatusException( + ResponseStatusException ex, Model model, HttpServletResponse response) { - int status = ex.getStatusCode().value(); - response.setStatus(status); + int status = ex.getStatusCode().value(); + response.setStatus(status); - model.addAttribute("status", status); - model.addAttribute("error", ex.getReason()); + model.addAttribute("status", status); + model.addAttribute("error", ex.getReason()); - return "error"; - } + return "error"; + } - @ExceptionHandler(Exception.class) - public String handleException( - Exception ex, - Model model, - HttpServletResponse response - ) { + @ExceptionHandler(Exception.class) + public String handleException(Exception ex, Model model, HttpServletResponse response) { - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - model.addAttribute("status", 500); - model.addAttribute("error", "Something went wrong"); + model.addAttribute("status", 500); + model.addAttribute("error", "Something went wrong"); - return "error"; - } -} \ No newline at end of file + return "error"; + } +} diff --git a/src/main/java/org/example/alfs/mapper/TicketCommentMapper.java b/src/main/java/org/example/alfs/mapper/TicketCommentMapper.java index 7fee2c5..ec1838f 100644 --- a/src/main/java/org/example/alfs/mapper/TicketCommentMapper.java +++ b/src/main/java/org/example/alfs/mapper/TicketCommentMapper.java @@ -6,22 +6,22 @@ @Component public class TicketCommentMapper { - public CommentViewDTO entityToViewDTO(TicketComment comment) { - CommentViewDTO dto = new CommentViewDTO(); + public CommentViewDTO entityToViewDTO(TicketComment comment) { + CommentViewDTO dto = new CommentViewDTO(); - dto.setId(comment.getId()); - dto.setMessage(comment.getMessage()); - dto.setCreatedAt(comment.getCreatedAt()); - dto.setInternalNote(comment.isInternalNote()); + dto.setId(comment.getId()); + dto.setMessage(comment.getMessage()); + dto.setCreatedAt(comment.getCreatedAt()); + dto.setInternalNote(comment.isInternalNote()); - if (comment.getAuthor() != null) { - dto.setAuthor(comment.getAuthor().getUsername()); - dto.setRole(comment.getAuthor().getRole().name()); - } else { - dto.setAuthor("Anonymous"); - dto.setRole(null); - } - - return dto; + if (comment.getAuthor() != null) { + dto.setAuthor(comment.getAuthor().getUsername()); + dto.setRole(comment.getAuthor().getRole().name()); + } else { + dto.setAuthor("Anonymous"); + dto.setRole(null); } + + return dto; + } } diff --git a/src/main/java/org/example/alfs/mapper/TicketMapper.java b/src/main/java/org/example/alfs/mapper/TicketMapper.java index 5de4831..3f8dd32 100644 --- a/src/main/java/org/example/alfs/mapper/TicketMapper.java +++ b/src/main/java/org/example/alfs/mapper/TicketMapper.java @@ -7,23 +7,21 @@ @Component public class TicketMapper { - public TicketViewDTO entityToViewDTO(Ticket ticket){ + public TicketViewDTO entityToViewDTO(Ticket ticket) { - TicketViewDTO ticketViewDTO = new TicketViewDTO(); + TicketViewDTO ticketViewDTO = new TicketViewDTO(); - ticketViewDTO.setId(ticket.getId()); - ticketViewDTO.setTitle(ticket.getTitle()); - ticketViewDTO.setDescription(ticket.getDescription()); - ticketViewDTO.setStatus(ticket.getStatus()); - ticketViewDTO.setCreatedAt(ticket.getCreatedAt()); + ticketViewDTO.setId(ticket.getId()); + ticketViewDTO.setTitle(ticket.getTitle()); + ticketViewDTO.setDescription(ticket.getDescription()); + ticketViewDTO.setStatus(ticket.getStatus()); + ticketViewDTO.setCreatedAt(ticket.getCreatedAt()); - if (ticket.getInvestigator() != null) { - ticketViewDTO.setAssignedInvestigatorId(ticket.getInvestigator().getId()); - ticketViewDTO.setAssignedInvestigatorName(ticket.getInvestigator().getUsername()); - } - - return ticketViewDTO; + if (ticket.getInvestigator() != null) { + ticketViewDTO.setAssignedInvestigatorId(ticket.getInvestigator().getId()); + ticketViewDTO.setAssignedInvestigatorName(ticket.getInvestigator().getUsername()); } - + return ticketViewDTO; + } } diff --git a/src/main/java/org/example/alfs/repositories/AttachmentRepository.java b/src/main/java/org/example/alfs/repositories/AttachmentRepository.java index e777d6d..c8ab070 100644 --- a/src/main/java/org/example/alfs/repositories/AttachmentRepository.java +++ b/src/main/java/org/example/alfs/repositories/AttachmentRepository.java @@ -1,13 +1,11 @@ package org.example.alfs.repositories; +import java.util.List; import org.example.alfs.entities.Attachment; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - public interface AttachmentRepository extends JpaRepository { - // Hämta alla bilagor i ett fall - List findByTicketId(Long ticketId); - + // Hämta alla bilagor i ett fall + List findByTicketId(Long ticketId); } diff --git a/src/main/java/org/example/alfs/repositories/AuditLogRepository.java b/src/main/java/org/example/alfs/repositories/AuditLogRepository.java index b211ce3..31dd951 100644 --- a/src/main/java/org/example/alfs/repositories/AuditLogRepository.java +++ b/src/main/java/org/example/alfs/repositories/AuditLogRepository.java @@ -1,13 +1,11 @@ package org.example.alfs.repositories; +import java.util.List; import org.example.alfs.entities.AuditLog; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - public interface AuditLogRepository extends JpaRepository { - // Hämta logghistoriken i ett fall, nyast först - List findByTicketIdOrderByCreatedAtDesc(Long ticketId); - + // Hämta logghistoriken i ett fall, nyast först + List findByTicketIdOrderByCreatedAtDesc(Long ticketId); } diff --git a/src/main/java/org/example/alfs/repositories/TicketCommentRepository.java b/src/main/java/org/example/alfs/repositories/TicketCommentRepository.java index 44730b3..6d228dd 100644 --- a/src/main/java/org/example/alfs/repositories/TicketCommentRepository.java +++ b/src/main/java/org/example/alfs/repositories/TicketCommentRepository.java @@ -1,18 +1,18 @@ package org.example.alfs.repositories; +import java.util.List; import org.example.alfs.entities.TicketComment; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; - public interface TicketCommentRepository extends JpaRepository { - // Ladda alla kommentarer i ett fall, äldst först - List findByTicketIdOrderByCreatedAtAsc(Long ticketId); + // Ladda alla kommentarer i ett fall, äldst först + List findByTicketIdOrderByCreatedAtAsc(Long ticketId); - // Ladda interna meddelanden för utredare/admins, äldst först - List findByTicketIdAndInternalNoteOrderByCreatedAtAsc(Long ticketId, boolean isInternalNote); + // Ladda interna meddelanden för utredare/admins, äldst först + List findByTicketIdAndInternalNoteOrderByCreatedAtAsc( + Long ticketId, boolean isInternalNote); - // Ladda kommentarer men utelämna interna meddelanden, äldst först - List findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(Long ticketId); + // Ladda kommentarer men utelämna interna meddelanden, äldst först + List findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(Long ticketId); } diff --git a/src/main/java/org/example/alfs/repositories/TicketRepository.java b/src/main/java/org/example/alfs/repositories/TicketRepository.java index 2aad7a1..edbf033 100644 --- a/src/main/java/org/example/alfs/repositories/TicketRepository.java +++ b/src/main/java/org/example/alfs/repositories/TicketRepository.java @@ -1,33 +1,30 @@ package org.example.alfs.repositories; +import java.util.List; +import java.util.Optional; import org.example.alfs.entities.Ticket; import org.example.alfs.enums.TicketStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; -import java.util.Optional; - public interface TicketRepository extends JpaRepository { - // Anonym anmälare ser sitt fall med token - Optional findByReporterToken(String reporterToken); - - // Inloggad anmälare ser sitt/sina fall - List findByReporterId(Long reporterId); - - // Utredare ser sina tilldelade fall - List findByInvestigatorId(Long investigatorId); + // Anonym anmälare ser sitt fall med token + Optional findByReporterToken(String reporterToken); - // Filtrera fall efter status - List findByStatus(TicketStatus status); + // Inloggad anmälare ser sitt/sina fall + List findByReporterId(Long reporterId); - // Filtrera fall efter status och utredare - List findByStatusAndInvestigatorId(TicketStatus status, Long investigatorId); + // Utredare ser sina tilldelade fall + List findByInvestigatorId(Long investigatorId); - // Hämta alla fall, paginerat - Page findAll(Pageable pageable); + // Filtrera fall efter status + List findByStatus(TicketStatus status); + // Filtrera fall efter status och utredare + List findByStatusAndInvestigatorId(TicketStatus status, Long investigatorId); + // Hämta alla fall, paginerat + Page findAll(Pageable pageable); } diff --git a/src/main/java/org/example/alfs/repositories/UserRepository.java b/src/main/java/org/example/alfs/repositories/UserRepository.java index 6614902..2383d90 100644 --- a/src/main/java/org/example/alfs/repositories/UserRepository.java +++ b/src/main/java/org/example/alfs/repositories/UserRepository.java @@ -1,17 +1,16 @@ package org.example.alfs.repositories; +import java.util.List; +import java.util.Optional; import org.example.alfs.entities.User; import org.example.alfs.enums.Role; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.List; -import java.util.Optional; - public interface UserRepository extends JpaRepository { - Optional findByUsername(String username); + Optional findByUsername(String username); + + boolean existsByUsername(String username); - boolean existsByUsername(String username); - - List findByRole(Role role); + List findByRole(Role role); } diff --git a/src/main/java/org/example/alfs/security/JwtAuthenticationFilter.java b/src/main/java/org/example/alfs/security/JwtAuthenticationFilter.java index 3559b73..ed56d6d 100644 --- a/src/main/java/org/example/alfs/security/JwtAuthenticationFilter.java +++ b/src/main/java/org/example/alfs/security/JwtAuthenticationFilter.java @@ -2,8 +2,11 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; import org.example.alfs.entities.User; import org.example.alfs.repositories.UserRepository; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -11,114 +14,108 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import jakarta.servlet.http.Cookie; -import java.io.IOException; -import java.util.List; @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtService jwtService; - private final UserRepository userRepository; - - public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) { - this.jwtService = jwtService; - this.userRepository = userRepository; + private final JwtService jwtService; + private final UserRepository userRepository; + + public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) { + this.jwtService = jwtService; + this.userRepository = userRepository; + } + + // Skips filter for LOGIN & H2 & START PAGE + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/auth") + || path.startsWith("/h2-console") + || path.startsWith("/startPage") + + // swagger + || path.startsWith("/swagger-ui") + || path.startsWith("/v3/api-docs"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + final String authHeader = request.getHeader("Authorization"); + + String jwt = null; + // Try to read JWT from Authorization header (used by API/Postman) + // Ignore empty Bearer tokens so cookie fallback still works + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String bearer = authHeader.substring(7).trim(); + if (!bearer.isEmpty()) { + jwt = bearer; + } } - // Skips filter for LOGIN & H2 & START PAGE - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - String path = request.getRequestURI(); - return path.startsWith("/auth") || path.startsWith("/h2-console") || path.startsWith("/startPage") - - //swagger - || path.startsWith("/swagger-ui") - || path.startsWith("/v3/api-docs"); - } - - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - final String authHeader = request.getHeader("Authorization"); - - String jwt = null; - // Try to read JWT from Authorization header (used by API/Postman) - // Ignore empty Bearer tokens so cookie fallback still works - if(authHeader != null && authHeader.startsWith("Bearer ")){ - String bearer = authHeader.substring(7).trim(); - if (!bearer.isEmpty()) { - jwt = bearer; - } - } - - // If no valid token in header, try reading JWT from cookies (browser) - if ((jwt == null || jwt.isBlank()) && request.getCookies() != null) { - for (Cookie cookie : request.getCookies()) { - if ("JWT".equals(cookie.getName())) { - jwt = cookie.getValue(); - break; - } - } + // If no valid token in header, try reading JWT from cookies (browser) + if ((jwt == null || jwt.isBlank()) && request.getCookies() != null) { + for (Cookie cookie : request.getCookies()) { + if ("JWT".equals(cookie.getName())) { + jwt = cookie.getValue(); + break; } + } + } - // if still no token, continue without auth - if(jwt == null) { - filterChain.doFilter(request, response); - return; - } + // if still no token, continue without auth + if (jwt == null) { + filterChain.doFilter(request, response); + return; + } + String username; + try { + username = jwtService.extractUsername(jwt); + } catch (Exception e) { + filterChain.doFilter(request, response); + return; + } - String username; - try { - username = jwtService.extractUsername(jwt); - } catch (Exception e) { - filterChain.doFilter(request, response); - return; - } + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { - if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { - - User user = userRepository.findByUsername(username).orElse(null); - - if (user == null) { - filterChain.doFilter(request, response); - return; - } - - // IMPORTANT: - // We do NOT trust the role stored in the JWT. - // Instead, we always load the user's role from the database. - // - // This ensures that if a user's role changes (e.g. ADMIN → REPORTER), - // the change takes effect immediately, even if the old JWT is still valid. - org.springframework.security.core.userdetails.UserDetails userDetails = - new org.springframework.security.core.userdetails.User( - user.getUsername(), - user.getPasswordHash(), - List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())) - ); - - boolean valid = jwtService.isTokenValid(jwt, user); - - if (valid) { - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - - authToken.setDetails( - new org.springframework.security.web.authentication.WebAuthenticationDetailsSource() - .buildDetails(request) - ); - - SecurityContextHolder.getContext().setAuthentication(authToken); - } - } + User user = userRepository.findByUsername(username).orElse(null); + if (user == null) { filterChain.doFilter(request, response); + return; + } + + // IMPORTANT: + // We do NOT trust the role stored in the JWT. + // Instead, we always load the user's role from the database. + // + // This ensures that if a user's role changes (e.g. ADMIN → REPORTER), + // the change takes effect immediately, even if the old JWT is still valid. + org.springframework.security.core.userdetails.UserDetails userDetails = + new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPasswordHash(), + List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()))); + + boolean valid = jwtService.isTokenValid(jwt, user); + + if (valid) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + authToken.setDetails( + new org.springframework.security.web.authentication.WebAuthenticationDetailsSource() + .buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authToken); + } } -} \ No newline at end of file + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/org/example/alfs/security/JwtService.java b/src/main/java/org/example/alfs/security/JwtService.java index c5d6938..31cd024 100644 --- a/src/main/java/org/example/alfs/security/JwtService.java +++ b/src/main/java/org/example/alfs/security/JwtService.java @@ -4,88 +4,82 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import org.example.alfs.entities.User; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -import javax.crypto.SecretKey; import java.util.Date; import java.util.HashMap; import java.util.Map; +import javax.crypto.SecretKey; +import org.example.alfs.entities.User; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; @Service public class JwtService { - @Value("${jwt.secret}") - private String secretKey; - - @Value("${jwt.expiration}") - private long jwtExpiration; - - public String generateToken(User user) { - Map claims = new HashMap<>(); - - - // NOTE: - // We include the user's role in the JWT for potential future use. - // However, the application does NOT use the role from the token for authorization. - // - // Instead, the user's role is always fetched from the database. - // This ensures that any changes to user permissions take effect immediately, - // without waiting for the JWT to expire. - // - // This design prioritizes security and consistency over performance. - claims.put("role", user.getRole().name()); - - return buildToken(claims, user.getUsername()); - } - - private String buildToken(Map claims, String username) { - return Jwts.builder() - .claims(claims) - .subject(username) - .issuedAt(new Date(System.currentTimeMillis())) - .expiration(new Date(System.currentTimeMillis() + jwtExpiration)) - .signWith(getSigningKey()) - .compact(); - } - - public String extractUsername(String token) { - return extractClaim(token, Claims::getSubject); - } - - public String extractRole(String token) { - return extractAllClaims(token).get("role", String.class); - } - - public boolean isTokenValid(String token, User user) { - final String username = extractUsername(token); - return username.equals(user.getUsername()) && !isTokenExpired(token); - } - - public boolean isTokenExpired(String token) { - return extractExpiration(token).before(new Date()); - } - - private Claims extractAllClaims(String token) { - return Jwts.parser() - .verifyWith(getSigningKey()) - .build() - .parseSignedClaims(token) - .getPayload(); - } - - private SecretKey getSigningKey() { - byte[] keyBytes = Decoders.BASE64.decode(secretKey); - return Keys.hmacShaKeyFor(keyBytes); - } - - private Date extractExpiration(String token) { - return extractClaim(token, Claims::getExpiration); - } - - private T extractClaim(String token, java.util.function.Function claimsResolver) { - Claims claims = extractAllClaims(token); - return claimsResolver.apply(claims); - } + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.expiration}") + private long jwtExpiration; + + public String generateToken(User user) { + Map claims = new HashMap<>(); + + // NOTE: + // We include the user's role in the JWT for potential future use. + // However, the application does NOT use the role from the token for authorization. + // + // Instead, the user's role is always fetched from the database. + // This ensures that any changes to user permissions take effect immediately, + // without waiting for the JWT to expire. + // + // This design prioritizes security and consistency over performance. + claims.put("role", user.getRole().name()); + + return buildToken(claims, user.getUsername()); + } + + private String buildToken(Map claims, String username) { + return Jwts.builder() + .claims(claims) + .subject(username) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + jwtExpiration)) + .signWith(getSigningKey()) + .compact(); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public String extractRole(String token) { + return extractAllClaims(token).get("role", String.class); + } + + public boolean isTokenValid(String token, User user) { + final String username = extractUsername(token); + return username.equals(user.getUsername()) && !isTokenExpired(token); + } + + public boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser().verifyWith(getSigningKey()).build().parseSignedClaims(token).getPayload(); + } + + private SecretKey getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private T extractClaim(String token, java.util.function.Function claimsResolver) { + Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } } diff --git a/src/main/java/org/example/alfs/security/SecurityUtils.java b/src/main/java/org/example/alfs/security/SecurityUtils.java index 9d76be1..94674fa 100644 --- a/src/main/java/org/example/alfs/security/SecurityUtils.java +++ b/src/main/java/org/example/alfs/security/SecurityUtils.java @@ -8,42 +8,43 @@ @Component public class SecurityUtils { - private final UserRepository userRepository; + private final UserRepository userRepository; - public SecurityUtils(UserRepository userRepository) { - this.userRepository = userRepository; - } + public SecurityUtils(UserRepository userRepository) { + this.userRepository = userRepository; + } - public User getCurrentUser() { + public User getCurrentUser() { - var authentication = SecurityContextHolder.getContext().getAuthentication(); + var authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || !authentication.isAuthenticated()) { - throw new RuntimeException("No authenticated user in security context"); - } + if (authentication == null || !authentication.isAuthenticated()) { + throw new RuntimeException("No authenticated user in security context"); + } - String username = authentication.getName(); + String username = authentication.getName(); - return userRepository.findByUsername(username) - .orElseThrow(() -> new RuntimeException("Authenticated user not found in database")); - } + return userRepository + .findByUsername(username) + .orElseThrow(() -> new RuntimeException("Authenticated user not found in database")); + } - public User getCurrentUserOrNull() { - try { - return getCurrentUser(); - } catch (RuntimeException ex) { + public User getCurrentUserOrNull() { + try { + return getCurrentUser(); + } catch (RuntimeException ex) { - String message = ex.getMessage(); + String message = ex.getMessage(); - boolean authFailure = - "No authenticated user in security context".equals(message) || - "Authenticated user not found in database".equals(message); + boolean authFailure = + "No authenticated user in security context".equals(message) + || "Authenticated user not found in database".equals(message); - if (authFailure) { - return null; - } + if (authFailure) { + return null; + } - throw ex; - } + throw ex; } -} \ No newline at end of file + } +} diff --git a/src/main/java/org/example/alfs/services/AttachmentService.java b/src/main/java/org/example/alfs/services/AttachmentService.java index c986a14..c964ff9 100644 --- a/src/main/java/org/example/alfs/services/AttachmentService.java +++ b/src/main/java/org/example/alfs/services/AttachmentService.java @@ -1,5 +1,6 @@ package org.example.alfs.services; +import java.util.List; import org.example.alfs.entities.Attachment; import org.example.alfs.entities.Ticket; import org.example.alfs.entities.User; @@ -14,128 +15,125 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import java.util.List; - @Service public class AttachmentService { - private final MinioStorageService storageService; - private final AttachmentRepository attachmentRepository; - private final TicketRepository ticketRepository; - private final AuditService auditService; + private final MinioStorageService storageService; + private final AttachmentRepository attachmentRepository; + private final TicketRepository ticketRepository; + private final AuditService auditService; + + public AttachmentService( + MinioStorageService storageService, + AttachmentRepository attachmentRepository, + TicketRepository ticketRepository, + AuditService auditService) { + this.storageService = storageService; + this.attachmentRepository = attachmentRepository; + this.ticketRepository = ticketRepository; + this.auditService = auditService; + } + + @Transactional + public Attachment uploadToTicket(Long ticketId, MultipartFile file, User user, String token) + throws Exception { + + Ticket ticket; + + if (user != null) { + // logged in + ticket = + ticketRepository + .findById(ticketId) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); + } else { + + // validate token first + if (token == null || token.isBlank()) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or missing token"); + } + + // anonymous via token + ticket = + ticketRepository + .findByReporterToken(token) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); + } + + checkAccess(ticket, user, token); + String objectKey = storageService.upload(file); - public AttachmentService(MinioStorageService storageService, - AttachmentRepository attachmentRepository, - TicketRepository ticketRepository, - AuditService auditService) { - this.storageService = storageService; - this.attachmentRepository = attachmentRepository; - this.ticketRepository = ticketRepository; - this.auditService = auditService; - } + try { + String fileName = file.getOriginalFilename(); + if (fileName == null || fileName.isBlank()) { + fileName = "file"; + } + + Attachment att = new Attachment(); + att.setFileName(fileName); + att.setS3Key(objectKey); + att.setTicket(ticket); + att.setUploadedBy(user); + + attachmentRepository.save(att); - @Transactional - public Attachment uploadToTicket(Long ticketId, MultipartFile file, User user, String token) throws Exception { - - Ticket ticket; - - if (user != null) { - // logged in - ticket = ticketRepository.findById(ticketId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); - } else { - - // validate token first - if (token == null || token.isBlank()) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or missing token"); - } - - // anonymous via token - ticket = ticketRepository.findByReporterToken(token) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); - } - - checkAccess(ticket, user, token); - - String objectKey = storageService.upload(file); - - try { - String fileName = file.getOriginalFilename(); - if (fileName == null || fileName.isBlank()) { - fileName = "file"; - } - - Attachment att = new Attachment(); - att.setFileName(fileName); - att.setS3Key(objectKey); - att.setTicket(ticket); - att.setUploadedBy(user); - - attachmentRepository.save(att); - - auditService.log( - AuditAction.ATTACHMENT_ADDED, - "attachments", - null, - att.getFileName(), - ticket, - user - ); - - return att; - - } catch (Exception e) { - try { - storageService.delete(objectKey); - } catch (Exception deleteEx) { - e.addSuppressed(deleteEx); - } - throw e; - } + auditService.log( + AuditAction.ATTACHMENT_ADDED, "attachments", null, att.getFileName(), ticket, user); + + return att; + + } catch (Exception e) { + try { + storageService.delete(objectKey); + } catch (Exception deleteEx) { + e.addSuppressed(deleteEx); + } + throw e; } + } - private void checkAccess(Ticket ticket, User user, String token) { - - // ANONYMOUS VIA TOKEN - if (user == null) { - if (token != null && token.equals(ticket.getReporterToken())) { - return; - } - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or missing token"); - } - - // ADMIN - if (user.getRole() == Role.ADMIN) return; - - // INVESTIGATOR - if (user.getRole() == Role.INVESTIGATOR) { - if (ticket.getInvestigator() != null && - ticket.getInvestigator().getId().equals(user.getId())) { - return; - } - } - - // REPORTER - if (user.getRole() == Role.REPORTER) { - if (ticket.getReporter() != null && - ticket.getReporter().getId().equals(user.getId())) { - return; - } - } - - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + private void checkAccess(Ticket ticket, User user, String token) { + + // ANONYMOUS VIA TOKEN + if (user == null) { + if (token != null && token.equals(ticket.getReporterToken())) { + return; + } + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or missing token"); } - public List getAttachmentsByTicketId(Long ticketId) { - return attachmentRepository.findByTicketId(ticketId); + // ADMIN + if (user.getRole() == Role.ADMIN) return; + + // INVESTIGATOR + if (user.getRole() == Role.INVESTIGATOR) { + if (ticket.getInvestigator() != null + && ticket.getInvestigator().getId().equals(user.getId())) { + return; + } } - public Attachment getAttachmentById(Long id) { - return attachmentRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, - "Attachment not found: " + id - )); + // REPORTER + if (user.getRole() == Role.REPORTER) { + if (ticket.getReporter() != null && ticket.getReporter().getId().equals(user.getId())) { + return; + } } + + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + } + + public List getAttachmentsByTicketId(Long ticketId) { + return attachmentRepository.findByTicketId(ticketId); + } + + public Attachment getAttachmentById(Long id) { + return attachmentRepository + .findById(id) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found: " + id)); + } } diff --git a/src/main/java/org/example/alfs/services/AuditService.java b/src/main/java/org/example/alfs/services/AuditService.java index f32f9e4..5e11068 100644 --- a/src/main/java/org/example/alfs/services/AuditService.java +++ b/src/main/java/org/example/alfs/services/AuditService.java @@ -1,5 +1,6 @@ package org.example.alfs.services; +import java.util.List; import org.example.alfs.entities.AuditLog; import org.example.alfs.entities.Ticket; import org.example.alfs.entities.User; @@ -7,43 +8,47 @@ import org.example.alfs.repositories.AuditLogRepository; import org.springframework.stereotype.Service; -import java.util.List; - @Service public class AuditService { - private final AuditLogRepository auditLogRepository; - - public AuditService(AuditLogRepository auditLogRepository) { - this.auditLogRepository = auditLogRepository; - } - - - // new with user - public void log(AuditAction action, String fieldName, String oldValue, String newValue, Ticket ticket, User user) { - AuditLog log = new AuditLog(); - log.setAction(action); - log.setFieldName(fieldName); - log.setOldValue(oldValue); - log.setNewValue(newValue); - log.setTicket(ticket); - log.setUser(user); - auditLogRepository.save(log); - } - - // keeping old for safety - public void log(AuditAction action, String fieldName, String oldValue, String newValue, Ticket ticket) { - AuditLog log = new AuditLog(); - log.setAction(action); - log.setFieldName(fieldName); - log.setOldValue(oldValue); - log.setNewValue(newValue); - log.setTicket(ticket); - // createdAt sätts automatiskt via @PrePersist i AuditLog - auditLogRepository.save(log); - } - - public List getAuditLogsForTicket(Long ticketId) { - return auditLogRepository.findByTicketIdOrderByCreatedAtDesc(ticketId); - } + private final AuditLogRepository auditLogRepository; + + public AuditService(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + // new with user + public void log( + AuditAction action, + String fieldName, + String oldValue, + String newValue, + Ticket ticket, + User user) { + AuditLog log = new AuditLog(); + log.setAction(action); + log.setFieldName(fieldName); + log.setOldValue(oldValue); + log.setNewValue(newValue); + log.setTicket(ticket); + log.setUser(user); + auditLogRepository.save(log); + } + + // keeping old for safety + public void log( + AuditAction action, String fieldName, String oldValue, String newValue, Ticket ticket) { + AuditLog log = new AuditLog(); + log.setAction(action); + log.setFieldName(fieldName); + log.setOldValue(oldValue); + log.setNewValue(newValue); + log.setTicket(ticket); + // createdAt sätts automatiskt via @PrePersist i AuditLog + auditLogRepository.save(log); + } + + public List getAuditLogsForTicket(Long ticketId) { + return auditLogRepository.findByTicketIdOrderByCreatedAtDesc(ticketId); + } } diff --git a/src/main/java/org/example/alfs/services/AuthService.java b/src/main/java/org/example/alfs/services/AuthService.java index a8cec36..f871d90 100644 --- a/src/main/java/org/example/alfs/services/AuthService.java +++ b/src/main/java/org/example/alfs/services/AuthService.java @@ -12,47 +12,45 @@ @Service public class AuthService { - private final PasswordEncoder passwordEncoder; - private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final UserRepository userRepository; - public AuthService(PasswordEncoder passwordEncoder, UserRepository userRepository) { - this.passwordEncoder = passwordEncoder; - this.userRepository = userRepository; - } - - /** - * Authenticates a user by verifying username and password. - */ - public User login(String username, String password) { - - User user = userRepository.findByUsername(username) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Bad credentials")); + public AuthService(PasswordEncoder passwordEncoder, UserRepository userRepository) { + this.passwordEncoder = passwordEncoder; + this.userRepository = userRepository; + } + /** Authenticates a user by verifying username and password. */ + public User login(String username, String password) { + User user = + userRepository + .findByUsername(username) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Bad credentials")); - if (!passwordEncoder.matches(password, user.getPasswordHash())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Bad credentials"); - } - - return user; + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Bad credentials"); } + return user; + } - /** - * Registers a new user by creating an account with a hashed password. - * The user is assigned the default role REPORTER. - */ - public void signup(SignupRequestDTO request) { + /** + * Registers a new user by creating an account with a hashed password. The user is assigned the + * default role REPORTER. + */ + public void signup(SignupRequestDTO request) { - if (userRepository.findByUsername(request.getUsername()).isPresent()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username already exists"); - } + if (userRepository.findByUsername(request.getUsername()).isPresent()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username already exists"); + } - User user = new User(); - user.setUsername(request.getUsername()); - user.setPasswordHash(passwordEncoder.encode(request.getPassword())); - user.setRole(Role.REPORTER); + User user = new User(); + user.setUsername(request.getUsername()); + user.setPasswordHash(passwordEncoder.encode(request.getPassword())); + user.setRole(Role.REPORTER); - userRepository.save(user); - } + userRepository.save(user); + } } diff --git a/src/main/java/org/example/alfs/services/DemoDataService.java b/src/main/java/org/example/alfs/services/DemoDataService.java index 9efcc7a..b8b99b5 100644 --- a/src/main/java/org/example/alfs/services/DemoDataService.java +++ b/src/main/java/org/example/alfs/services/DemoDataService.java @@ -1,5 +1,7 @@ package org.example.alfs.services; +import java.util.List; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.alfs.entities.*; @@ -11,227 +13,198 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.UUID; - @Service @Slf4j @RequiredArgsConstructor public class DemoDataService { - private final UserRepository userRepository; - private final TicketRepository ticketRepository; - private final TicketCommentRepository commentRepository; - private final AttachmentRepository attachmentRepository; - private final AuditLogRepository auditLogRepository; - private final PasswordEncoder passwordEncoder; - - @Transactional - public void seedDemoData() { - if (userRepository.existsByUsername("admin")) { - log.info("Database already contains data, skipping demo data seeding."); - return; - } - - log.info("Seeding demo data..."); - - SeedUsers users = createUsers(); - SeedTickets tickets = createTickets(users); - createComments(tickets, users); - createAttachments(tickets, users); - - log.info("Demo data seeding completed."); + private final UserRepository userRepository; + private final TicketRepository ticketRepository; + private final TicketCommentRepository commentRepository; + private final AttachmentRepository attachmentRepository; + private final AuditLogRepository auditLogRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public void seedDemoData() { + if (userRepository.existsByUsername("admin")) { + log.info("Database already contains data, skipping demo data seeding."); + return; } - private SeedUsers createUsers() { - User admin = new User(); - admin.setUsername("admin"); - admin.setPasswordHash(passwordEncoder.encode("admin")); - admin.setRole(Role.ADMIN); - - User investigator1 = new User(); - investigator1.setUsername("investigator1"); - investigator1.setPasswordHash(passwordEncoder.encode("investigator1")); - investigator1.setRole(Role.INVESTIGATOR); - - User investigator2 = new User(); - investigator2.setUsername("investigator2"); - investigator2.setPasswordHash(passwordEncoder.encode("investigator2")); - investigator2.setRole(Role.INVESTIGATOR); - - User reporter1 = new User(); - reporter1.setUsername("reporter1"); - reporter1.setPasswordHash(passwordEncoder.encode("reporter1")); - reporter1.setRole(Role.REPORTER); - - User reporter2 = new User(); - reporter2.setUsername("reporter2"); - reporter2.setPasswordHash(passwordEncoder.encode("reporter2")); - reporter2.setRole(Role.REPORTER); - - userRepository.saveAll(List.of( - admin, - investigator1, - investigator2, - reporter1, - reporter2 - )); - - return new SeedUsers(admin, investigator1, investigator2, reporter1, reporter2); + log.info("Seeding demo data..."); + + SeedUsers users = createUsers(); + SeedTickets tickets = createTickets(users); + createComments(tickets, users); + createAttachments(tickets, users); + + log.info("Demo data seeding completed."); + } + + private SeedUsers createUsers() { + User admin = new User(); + admin.setUsername("admin"); + admin.setPasswordHash(passwordEncoder.encode("admin")); + admin.setRole(Role.ADMIN); + + User investigator1 = new User(); + investigator1.setUsername("investigator1"); + investigator1.setPasswordHash(passwordEncoder.encode("investigator1")); + investigator1.setRole(Role.INVESTIGATOR); + + User investigator2 = new User(); + investigator2.setUsername("investigator2"); + investigator2.setPasswordHash(passwordEncoder.encode("investigator2")); + investigator2.setRole(Role.INVESTIGATOR); + + User reporter1 = new User(); + reporter1.setUsername("reporter1"); + reporter1.setPasswordHash(passwordEncoder.encode("reporter1")); + reporter1.setRole(Role.REPORTER); + + User reporter2 = new User(); + reporter2.setUsername("reporter2"); + reporter2.setPasswordHash(passwordEncoder.encode("reporter2")); + reporter2.setRole(Role.REPORTER); + + userRepository.saveAll(List.of(admin, investigator1, investigator2, reporter1, reporter2)); + + return new SeedUsers(admin, investigator1, investigator2, reporter1, reporter2); + } + + private record SeedUsers( + User admin, User investigator1, User investigator2, User reporter1, User reporter2) {} + + private SeedTickets createTickets(SeedUsers u) { + Ticket t1 = + addTicket( + "Corruption case", "Procurement issue", u.reporter1(), u.investigator1(), u.admin()); + changeStatus(t1, u.investigator1(), TicketStatus.IN_PROGRESS); + + Ticket t2 = + addTicket( + "Anonymous harassment", + "Ongoing issue", + null, // Anonymous reporter + u.investigator2(), + u.admin()); + changeStatus(t2, u.investigator2(), TicketStatus.IN_PROGRESS); + + Ticket t3 = + addTicket( + "Financial misreporting", + "Accounting irregularities", + u.reporter2(), + u.investigator1(), + u.admin()); + changeStatus(t3, u.investigator1(), TicketStatus.IN_PROGRESS); + changeStatus(t3, u.investigator1(), TicketStatus.RESOLVED); + + Ticket t4 = addTicket("Unauthorized access", "Security issue", u.reporter1(), null, u.admin()); + + return new SeedTickets(t1, t2, t3, t4); + } + + private record SeedTickets(Ticket t1, Ticket t2, Ticket t3, Ticket t4) {} + + private Ticket addTicket( + String title, String description, User reporter, User investigator, User admin) { + Ticket t = new Ticket(); + + t.setTitle(title); + t.setDescription(description); + t.setStatus(TicketStatus.OPEN); + t.setReporter(reporter); + t.setInvestigator(investigator); + + if (reporter == null) { + t.setReporterToken(UUID.randomUUID().toString()); } - private record SeedUsers( - User admin, - User investigator1, - User investigator2, - User reporter1, - User reporter2 - ) { - } + t = ticketRepository.save(t); - private SeedTickets createTickets(SeedUsers u) { - Ticket t1 = addTicket( - "Corruption case", - "Procurement issue", - u.reporter1(), - u.investigator1(), - u.admin() - ); - changeStatus(t1, u.investigator1(), TicketStatus.IN_PROGRESS); - - Ticket t2 = addTicket( - "Anonymous harassment", - "Ongoing issue", - null, // Anonymous reporter - u.investigator2(), - u.admin() - ); - changeStatus(t2, u.investigator2(), TicketStatus.IN_PROGRESS); - - Ticket t3 = addTicket( - "Financial misreporting", - "Accounting irregularities", - u.reporter2(), - u.investigator1(), - u.admin() - ); - changeStatus(t3, u.investigator1(), TicketStatus.IN_PROGRESS); - changeStatus(t3, u.investigator1(), TicketStatus.RESOLVED); - - Ticket t4 = addTicket( - "Unauthorized access", - "Security issue", - u.reporter1(), - null, - u.admin() - ); - - return new SeedTickets(t1, t2, t3, t4); + if (reporter == null) { + addAuditLog(t, null, AuditAction.CREATED, "ticket", null, "created (anonymous)"); + } else { + addAuditLog(t, reporter, AuditAction.CREATED, "ticket", null, "created"); } - private record SeedTickets( - Ticket t1, - Ticket t2, - Ticket t3, - Ticket t4 - ) { + if (investigator != null) { + addAuditLog(t, admin, AuditAction.ASSIGNED, "investigator", null, investigator.getUsername()); } - private Ticket addTicket(String title, String description, User reporter, User investigator, User admin) { - Ticket t = new Ticket(); + return t; + } - t.setTitle(title); - t.setDescription(description); - t.setStatus(TicketStatus.OPEN); - t.setReporter(reporter); - t.setInvestigator(investigator); + private void createComments(SeedTickets t, SeedUsers u) { + addComment(t.t1(), u.reporter1(), "Can someone look into this ASAP?"); + addComment(t.t1(), u.investigator1(), "We are investigating this matter."); + addComment(t.t1(), u.reporter1(), "Thank you for the update."); - if (reporter == null) { - t.setReporterToken(UUID.randomUUID().toString()); - } + addComment(t.t2(), null, "This is ongoing for months."); + addComment(t.t2(), u.investigator2(), "We take this seriously and will escalate."); - t = ticketRepository.save(t); + addComment(t.t3(), u.investigator1(), "Issue identified and resolved."); - if (reporter == null) { - addAuditLog(t, null, AuditAction.CREATED, "ticket", null, "created (anonymous)"); - } else { - addAuditLog(t, reporter, AuditAction.CREATED, "ticket", null, "created"); - } + addComment(t.t4(), u.reporter1(), "No response yet."); + } - if (investigator != null) { - addAuditLog(t, admin, AuditAction.ASSIGNED, "investigator", null, investigator.getUsername()); - } + private void addComment(Ticket ticket, User author, String message) { + TicketComment c = new TicketComment(); - return t; - } + c.setTicket(ticket); + c.setAuthor(author); + c.setMessage(message); + c.setInternalNote(false); - private void createComments(SeedTickets t, SeedUsers u) { - addComment(t.t1(), u.reporter1(), "Can someone look into this ASAP?"); - addComment(t.t1(), u.investigator1(), "We are investigating this matter."); - addComment(t.t1(), u.reporter1(), "Thank you for the update."); + commentRepository.save(c); - addComment(t.t2(), null, "This is ongoing for months."); - addComment(t.t2(), u.investigator2(), "We take this seriously and will escalate."); + addAuditLog(ticket, author, AuditAction.COMMENT_ADDED, "comment", null, message); + } - addComment(t.t3(), u.investigator1(), "Issue identified and resolved."); + private void createAttachments(SeedTickets t, SeedUsers u) { + addAttachment(t.t1(), "procurement-doc.pdf", u.reporter1()); - addComment(t.t4(), u.reporter1(), "No response yet."); - } + addAttachment(t.t2(), "complaint-evidence.txt", null); - private void addComment(Ticket ticket, User author, String message) { - TicketComment c = new TicketComment(); + addAttachment(t.t3(), "financial-report.xlsx", u.investigator1()); + } - c.setTicket(ticket); - c.setAuthor(author); - c.setMessage(message); - c.setInternalNote(false); + private void addAttachment(Ticket ticket, String fileName, User actor) { + Attachment a = new Attachment(); - commentRepository.save(c); + a.setTicket(ticket); + a.setFileName(fileName); + a.setS3Key("demo/" + UUID.randomUUID()); - addAuditLog(ticket, author, AuditAction.COMMENT_ADDED, "comment", null, message); - } - - private void createAttachments(SeedTickets t, SeedUsers u) { - addAttachment(t.t1(), "procurement-doc.pdf", u.reporter1()); - - addAttachment(t.t2(), "complaint-evidence.txt", null); + attachmentRepository.save(a); - addAttachment(t.t3(), "financial-report.xlsx", u.investigator1()); - } + addAuditLog(ticket, actor, AuditAction.ATTACHMENT_ADDED, "attachment", null, fileName); + } - private void addAttachment(Ticket ticket, String fileName, User actor) { - Attachment a = new Attachment(); + private void addAuditLog( + Ticket ticket, User user, AuditAction action, String field, String oldVal, String newVal) { + AuditLog entry = new AuditLog(); - a.setTicket(ticket); - a.setFileName(fileName); - a.setS3Key("demo/" + UUID.randomUUID()); + entry.setTicket(ticket); + entry.setUser(user); + entry.setAction(action); + entry.setFieldName(field); + entry.setOldValue(oldVal); + entry.setNewValue(newVal); - attachmentRepository.save(a); + auditLogRepository.save(entry); + } - addAuditLog(ticket, actor, AuditAction.ATTACHMENT_ADDED, "attachment", null, fileName); - } + private void changeStatus(Ticket t, User user, TicketStatus newStatus) { + String oldStatus = t.getStatus().name(); - private void addAuditLog(Ticket ticket, User user, AuditAction action, String field, String oldVal, String newVal) { - AuditLog entry = new AuditLog(); + if (t.getStatus() == newStatus) return; - entry.setTicket(ticket); - entry.setUser(user); - entry.setAction(action); - entry.setFieldName(field); - entry.setOldValue(oldVal); - entry.setNewValue(newVal); + t.setStatus(newStatus); + ticketRepository.save(t); - auditLogRepository.save(entry); - } - - private void changeStatus(Ticket t, User user, TicketStatus newStatus) { - String oldStatus = t.getStatus().name(); - - if (t.getStatus() == newStatus) return; - - t.setStatus(newStatus); - ticketRepository.save(t); - - addAuditLog(t, user, AuditAction.STATUS_CHANGED, "status", oldStatus, newStatus.name()); - } + addAuditLog(t, user, AuditAction.STATUS_CHANGED, "status", oldStatus, newStatus.name()); + } } diff --git a/src/main/java/org/example/alfs/services/TicketCommentService.java b/src/main/java/org/example/alfs/services/TicketCommentService.java index 1cc1c95..78879d1 100644 --- a/src/main/java/org/example/alfs/services/TicketCommentService.java +++ b/src/main/java/org/example/alfs/services/TicketCommentService.java @@ -1,5 +1,6 @@ package org.example.alfs.services; +import java.util.List; import org.example.alfs.dto.comment.CommentCreateDTO; import org.example.alfs.dto.comment.CommentViewDTO; import org.example.alfs.entities.Ticket; @@ -14,97 +15,102 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.util.List; - @Service public class TicketCommentService { - private final TicketRepository ticketRepository; - private final TicketCommentRepository ticketCommentRepository; - private final TicketCommentMapper ticketCommentMapper; - - public TicketCommentService(TicketRepository ticketRepository, - TicketCommentRepository ticketCommentRepository, - TicketCommentMapper ticketCommentMapper) { - this.ticketRepository = ticketRepository; - this.ticketCommentRepository = ticketCommentRepository; - this.ticketCommentMapper = ticketCommentMapper; - } - - @Transactional - public CommentViewDTO addComment(Long ticketId, CommentCreateDTO dto, User author, String token) { - Ticket ticket = ticketRepository.findById(ticketId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); - - boolean internalNote = dto.isInternalNote(); - - checkAccess(ticket, author, token); - checkInternalNotePermission(internalNote, author); - - TicketComment comment = new TicketComment(); - comment.setTicket(ticket); - comment.setAuthor(author); - comment.setMessage(dto.getMessage()); - comment.setInternalNote(internalNote); - - TicketComment savedComment = ticketCommentRepository.save(comment); - return ticketCommentMapper.entityToViewDTO(savedComment); + private final TicketRepository ticketRepository; + private final TicketCommentRepository ticketCommentRepository; + private final TicketCommentMapper ticketCommentMapper; + + public TicketCommentService( + TicketRepository ticketRepository, + TicketCommentRepository ticketCommentRepository, + TicketCommentMapper ticketCommentMapper) { + this.ticketRepository = ticketRepository; + this.ticketCommentRepository = ticketCommentRepository; + this.ticketCommentMapper = ticketCommentMapper; + } + + @Transactional + public CommentViewDTO addComment(Long ticketId, CommentCreateDTO dto, User author, String token) { + Ticket ticket = + ticketRepository + .findById(ticketId) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); + + boolean internalNote = dto.isInternalNote(); + + checkAccess(ticket, author, token); + checkInternalNotePermission(internalNote, author); + + TicketComment comment = new TicketComment(); + comment.setTicket(ticket); + comment.setAuthor(author); + comment.setMessage(dto.getMessage()); + comment.setInternalNote(internalNote); + + TicketComment savedComment = ticketCommentRepository.save(comment); + return ticketCommentMapper.entityToViewDTO(savedComment); + } + + @Transactional(readOnly = true) + public List getComments(Long ticketId, User user, String token) { + + Ticket ticket = + ticketRepository + .findById(ticketId) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); + + checkAccess(ticket, user, token); + + boolean isReporter = user != null && user.getRole() == Role.REPORTER; + boolean isAnonymous = user == null; + + List all = + (isReporter || isAnonymous) + ? ticketCommentRepository.findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc( + ticketId) + : ticketCommentRepository.findByTicketIdOrderByCreatedAtAsc(ticketId); + + return all.stream().map(ticketCommentMapper::entityToViewDTO).toList(); + } + + // helpers + private void checkAccess(Ticket ticket, User user, String token) { + // Authenticated + if (user != null) { + if (user.getRole() == Role.ADMIN) return; + + if (user.getRole() == Role.INVESTIGATOR + && ticket.getInvestigator() != null + && ticket.getInvestigator().getId().equals(user.getId())) { + return; + } + + if (user.getRole() == Role.REPORTER + && ticket.getReporter() != null + && ticket.getReporter().getId().equals(user.getId())) { + return; + } } - @Transactional(readOnly = true) - public List getComments(Long ticketId, User user, String token) { - - Ticket ticket = ticketRepository.findById(ticketId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); - - checkAccess(ticket, user, token); - - boolean isReporter = user != null && user.getRole() == Role.REPORTER; - boolean isAnonymous = user == null; - - List all = (isReporter || isAnonymous) - ? ticketCommentRepository.findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(ticketId) - : ticketCommentRepository.findByTicketIdOrderByCreatedAtAsc(ticketId); - - return all.stream() - .map(ticketCommentMapper::entityToViewDTO) - .toList(); + // Anonymous + if (token != null && !token.isBlank() && token.equals(ticket.getReporterToken())) { + return; } - // helpers - private void checkAccess(Ticket ticket, User user, String token) { - // Authenticated - if (user != null) { - if (user.getRole() == Role.ADMIN) return; - - if (user.getRole() == Role.INVESTIGATOR && - ticket.getInvestigator() != null && - ticket.getInvestigator().getId().equals(user.getId())) { - return; - } - - if (user.getRole() == Role.REPORTER && - ticket.getReporter() != null && - ticket.getReporter().getId().equals(user.getId())) { - return; - } - } - - // Anonymous - if (token != null && !token.isBlank() - && token.equals(ticket.getReporterToken())) { - return; - } - - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); - } + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + } - private void checkInternalNotePermission(boolean internalNote, User author) { - if (!internalNote) return; + private void checkInternalNotePermission(boolean internalNote, User author) { + if (!internalNote) return; - if (author == null || (author.getRole() != Role.ADMIN && author.getRole() != Role.INVESTIGATOR)) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Only investigators/admins can create internal notes"); - } + if (author == null + || (author.getRole() != Role.ADMIN && author.getRole() != Role.INVESTIGATOR)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Only investigators/admins can create internal notes"); } -} \ No newline at end of file + } +} diff --git a/src/main/java/org/example/alfs/services/TicketService.java b/src/main/java/org/example/alfs/services/TicketService.java index fb474a7..848b8a6 100644 --- a/src/main/java/org/example/alfs/services/TicketService.java +++ b/src/main/java/org/example/alfs/services/TicketService.java @@ -1,5 +1,8 @@ package org.example.alfs.services; +import java.util.List; +import java.util.Map; +import java.util.Set; import org.example.alfs.dto.ticket.TicketCreateDTO; import org.example.alfs.dto.ticket.TicketViewDTO; import org.example.alfs.entities.Ticket; @@ -16,377 +19,345 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.util.List; -import java.util.Map; -import java.util.Set; - @Service public class TicketService { - private final TicketRepository ticketRepository; - private final TicketMapper ticketMapper; - private final SecurityUtils securityUtils; - private final UserRepository userRepository; - private final AuditService auditService; - - public TicketService(TicketRepository ticketRepository, - TicketMapper ticketMapper, - SecurityUtils securityUtils, - UserRepository userRepository, - AuditService auditService) { - this.ticketRepository = ticketRepository; - this.ticketMapper = ticketMapper; - this.securityUtils = securityUtils; - this.userRepository = userRepository; - this.auditService = auditService; + private final TicketRepository ticketRepository; + private final TicketMapper ticketMapper; + private final SecurityUtils securityUtils; + private final UserRepository userRepository; + private final AuditService auditService; + + public TicketService( + TicketRepository ticketRepository, + TicketMapper ticketMapper, + SecurityUtils securityUtils, + UserRepository userRepository, + AuditService auditService) { + this.ticketRepository = ticketRepository; + this.ticketMapper = ticketMapper; + this.securityUtils = securityUtils; + this.userRepository = userRepository; + this.auditService = auditService; + } + + // createNewTicket + @Transactional + public TicketViewDTO createNewTicket(TicketCreateDTO dto) { + + Ticket ticket = new Ticket(); + + ticket.setTitle(dto.getTitle()); + ticket.setDescription(dto.getDescription()); + + User user = securityUtils.getCurrentUserOrNull(); + + String token = null; + + if (user != null) { + ticket.setReporter(user); + } else { + token = java.util.UUID.randomUUID().toString(); + ticket.setReporterToken(token); } - //createNewTicket - @Transactional - public TicketViewDTO createNewTicket(TicketCreateDTO dto) { + Ticket saved = ticketRepository.save(ticket); - Ticket ticket = new Ticket(); + auditService.log(AuditAction.CREATED, "title", null, saved.getTitle(), saved, user); - ticket.setTitle(dto.getTitle()); - ticket.setDescription(dto.getDescription()); + TicketViewDTO view = ticketMapper.entityToViewDTO(saved); - User user = securityUtils.getCurrentUserOrNull(); + if (token != null) { + view.setToken(token); + } - String token = null; + return view; + } - if (user != null) { - ticket.setReporter(user); - } else { - token = java.util.UUID.randomUUID().toString(); - ticket.setReporterToken(token); - } + // View by token + public TicketViewDTO getTicketByToken(String token) { + Ticket ticket = + ticketRepository + .findByReporterToken(token) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); - Ticket saved = ticketRepository.save(ticket); + TicketViewDTO view = ticketMapper.entityToViewDTO(ticket); - auditService.log( - AuditAction.CREATED, - "title", - null, - saved.getTitle(), - saved, - user - ); + view.setToken(ticket.getReporterToken()); - TicketViewDTO view = ticketMapper.entityToViewDTO(saved); + return view; + } - if (token != null) { - view.setToken(token); - } + // findById + public TicketViewDTO getTicketById(Long id) { + Ticket ticket = + ticketRepository + .findById(id) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); - return view; - } + checkAccess(ticket); + return ticketMapper.entityToViewDTO(ticket); + } - // View by token - public TicketViewDTO getTicketByToken(String token) { - Ticket ticket = ticketRepository.findByReporterToken(token) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); + // Get all tickets for a reporter + public List getMyTickets() { + User user = requireCurrentUser(); - TicketViewDTO view = ticketMapper.entityToViewDTO(ticket); + return ticketRepository.findByReporterId(user.getId()).stream() + .map(ticketMapper::entityToViewDTO) + .toList(); + } - view.setToken(ticket.getReporterToken()); + // Get all tickets assigned to me + public List getMyAssignedTickets() { + User user = requireCurrentUser(); - return view; - } + return ticketRepository.findByInvestigatorId(user.getId()).stream() + .map(ticketMapper::entityToViewDTO) + .toList(); + } - //findById - public TicketViewDTO getTicketById(Long id) { - Ticket ticket = ticketRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); + // this is for admin page + public List getAllTickets() { - checkAccess(ticket); + User user = requireCurrentUser(); + requireAdmin(user); - return ticketMapper.entityToViewDTO(ticket); - } + return ticketRepository.findAll().stream().map(ticketMapper::entityToViewDTO).toList(); + } - // Get all tickets for a reporter - public List getMyTickets() { - User user = requireCurrentUser(); + // ----------------- filters ----------------- - return ticketRepository.findByReporterId(user.getId()) - .stream() - .map(ticketMapper::entityToViewDTO) - .toList(); - } - - // Get all tickets assigned to me - public List getMyAssignedTickets() { - User user = requireCurrentUser(); + public List getTicketsByStatus(TicketStatus status) { + User user = requireCurrentUser(); + requireAdmin(user); - return ticketRepository.findByInvestigatorId(user.getId()) - .stream() - .map(ticketMapper::entityToViewDTO) - .toList(); - } + return ticketRepository.findByStatus(status).stream() + .map(ticketMapper::entityToViewDTO) + .toList(); + } - // this is for admin page - public List getAllTickets() { + public List getTicketsByStatusAndInvestigator( + TicketStatus status, Long investigatorId) { + User user = requireCurrentUser(); - User user = requireCurrentUser(); - requireAdmin(user); - - return ticketRepository.findAll() - .stream() - .map(ticketMapper::entityToViewDTO) - .toList(); + if (user.getRole() == Role.ADMIN) { + return ticketRepository.findByStatusAndInvestigatorId(status, investigatorId).stream() + .map(ticketMapper::entityToViewDTO) + .toList(); } - // ----------------- filters ----------------- - - public List getTicketsByStatus(TicketStatus status) { - User user = requireCurrentUser(); - requireAdmin(user); + if (user.getRole() == Role.INVESTIGATOR && user.getId().equals(investigatorId)) { - return ticketRepository.findByStatus(status) - .stream() - .map(ticketMapper::entityToViewDTO) - .toList(); + return ticketRepository.findByStatusAndInvestigatorId(status, investigatorId).stream() + .map(ticketMapper::entityToViewDTO) + .toList(); } - public List getTicketsByStatusAndInvestigator(TicketStatus status, Long investigatorId) { - User user = requireCurrentUser(); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + } - if (user.getRole() == Role.ADMIN) { - return ticketRepository.findByStatusAndInvestigatorId(status, investigatorId) - .stream() - .map(ticketMapper::entityToViewDTO) - .toList(); - } + // ----------------- helpers ----------------- - if (user.getRole() == Role.INVESTIGATOR && - user.getId().equals(investigatorId)) { + private void checkAccess(Ticket ticket) { + User user = requireCurrentUser(); - return ticketRepository.findByStatusAndInvestigatorId(status, investigatorId) - .stream() - .map(ticketMapper::entityToViewDTO) - .toList(); - } + if (user.getRole() == Role.ADMIN) return; - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + if (user.getRole() == Role.INVESTIGATOR) { + if (ticket.getInvestigator() != null + && ticket.getInvestigator().getId().equals(user.getId())) { + return; + } } - // ----------------- helpers ----------------- - - private void checkAccess(Ticket ticket) { - User user = requireCurrentUser(); - - if (user.getRole() == Role.ADMIN) return; - - if (user.getRole() == Role.INVESTIGATOR) { - if (ticket.getInvestigator() != null && - ticket.getInvestigator().getId().equals(user.getId())) { - return; - } - } - - if (user.getRole() == Role.REPORTER) { - if (ticket.getReporter() != null && - ticket.getReporter().getId().equals(user.getId())) { - return; - } - } - - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + if (user.getRole() == Role.REPORTER) { + if (ticket.getReporter() != null && ticket.getReporter().getId().equals(user.getId())) { + return; + } } - private void requireAdmin(User user) { - if (user.getRole() != Role.ADMIN) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); - } + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + } + + private void requireAdmin(User user) { + if (user.getRole() != Role.ADMIN) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); } + } - private User requireCurrentUser() { - try { - return securityUtils.getCurrentUser(); - } catch (RuntimeException ex) { + private User requireCurrentUser() { + try { + return securityUtils.getCurrentUser(); + } catch (RuntimeException ex) { - String message = ex.getMessage(); + String message = ex.getMessage(); - boolean authFailure = - "No authenticated user in security context".equals(message) || - "Authenticated user not found in database".equals(message); + boolean authFailure = + "No authenticated user in security context".equals(message) + || "Authenticated user not found in database".equals(message); - if (authFailure) { - throw new ResponseStatusException( - HttpStatus.UNAUTHORIZED, - "Authentication required", - ex - ); - } + if (authFailure) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required", ex); + } - throw ex; - } + throw ex; + } + } + + // ----------------- status logic ----------------- + @Transactional + public TicketViewDTO updateTicketStatus(Long id, TicketStatus newStatus) { + + User user = requireCurrentUser(); + Ticket ticket = + ticketRepository + .findById(id) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); + + if (user.getRole() != Role.ADMIN) { + boolean isAssignedInvestigator = + user.getRole() == Role.INVESTIGATOR + && ticket.getInvestigator() != null + && ticket.getInvestigator().getId().equals(user.getId()); + + if (!isAssignedInvestigator) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + } } + TicketStatus currentStatus = ticket.getStatus(); - // ----------------- status logic ----------------- - @Transactional - public TicketViewDTO updateTicketStatus(Long id, TicketStatus newStatus) { - - User user = requireCurrentUser(); - Ticket ticket = ticketRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, "Ticket not found")); - - if (user.getRole() != Role.ADMIN) { - boolean isAssignedInvestigator = - user.getRole() == Role.INVESTIGATOR && - ticket.getInvestigator() != null && - ticket.getInvestigator().getId().equals(user.getId()); + if (currentStatus == newStatus) { + return ticketMapper.entityToViewDTO(ticket); + } - if (!isAssignedInvestigator) { - throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "Access denied"); - } - } + Set allowedTransitions = + ALLOWED_TRANSITIONS.getOrDefault(currentStatus, Set.of()); - TicketStatus currentStatus = ticket.getStatus(); + boolean isValidTransition = allowedTransitions.contains(newStatus); - if (currentStatus == newStatus) { - return ticketMapper.entityToViewDTO(ticket); - } + if (!isValidTransition) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Invalid transition from " + currentStatus + " to " + newStatus); + } - Set allowedTransitions = ALLOWED_TRANSITIONS.getOrDefault(currentStatus, Set.of()); + if (newStatus == TicketStatus.IN_PROGRESS && ticket.getInvestigator() == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Cannot move to IN_PROGRESS without investigator"); + } - boolean isValidTransition = allowedTransitions.contains(newStatus); + ticket.setStatus(newStatus); - if (!isValidTransition) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Invalid transition from " + currentStatus + " to " + newStatus); - } + Ticket savedTicket = ticketRepository.save(ticket); - if (newStatus == TicketStatus.IN_PROGRESS && ticket.getInvestigator() == null) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Cannot move to IN_PROGRESS without investigator"); - } + auditService.log( + AuditAction.STATUS_CHANGED, + "status", + currentStatus.name(), + newStatus.name(), + savedTicket, + user); + return ticketMapper.entityToViewDTO(savedTicket); + } - ticket.setStatus(newStatus); + private static final Map> ALLOWED_TRANSITIONS = + Map.of( + TicketStatus.OPEN, Set.of(TicketStatus.IN_PROGRESS), + TicketStatus.IN_PROGRESS, Set.of(TicketStatus.RESOLVED), + TicketStatus.RESOLVED, Set.of(TicketStatus.CLOSED), + TicketStatus.CLOSED, Set.of()); - Ticket savedTicket = ticketRepository.save(ticket); + @Transactional + public TicketViewDTO assignInvestigator(Long id, Long investigatorId) { - auditService.log( - AuditAction.STATUS_CHANGED, - "status", - currentStatus.name(), - newStatus.name(), - savedTicket, - user - ); - return ticketMapper.entityToViewDTO(savedTicket); + if (investigatorId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Investigator ID is required"); } - private static final Map> ALLOWED_TRANSITIONS = Map.of( - TicketStatus.OPEN, Set.of(TicketStatus.IN_PROGRESS), - TicketStatus.IN_PROGRESS, Set.of(TicketStatus.RESOLVED), - TicketStatus.RESOLVED, Set.of(TicketStatus.CLOSED), - TicketStatus.CLOSED, Set.of() - ); - + User user = requireCurrentUser(); + requireAdmin(user); - @Transactional - public TicketViewDTO assignInvestigator(Long id, Long investigatorId) { + Ticket ticket = + ticketRepository + .findById(id) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); - if (investigatorId == null) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Investigator ID is required"); - } - - User user = requireCurrentUser(); - requireAdmin(user); + if (ticket.getInvestigator() != null) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "Ticket already has an investigator assigned"); + } - Ticket ticket = ticketRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, "Ticket not found")); + if (ticket.getStatus() != TicketStatus.OPEN) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Ticket must be in OPEN status to assign an investigator"); + } - if (ticket.getInvestigator() != null) { - throw new ResponseStatusException( - HttpStatus.CONFLICT, "Ticket already has an investigator assigned"); - } + User investigator = + userRepository + .findById(investigatorId) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Investigator not found")); - if (ticket.getStatus() != TicketStatus.OPEN) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Ticket must be in OPEN status to assign an investigator"); - } + if (investigator.getRole() != Role.INVESTIGATOR) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "User is not an investigator"); + } - User investigator = userRepository.findById(investigatorId) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, "Investigator not found")); + TicketStatus oldStatus = ticket.getStatus(); + ticket.setInvestigator(investigator); + ticket.setStatus(TicketStatus.IN_PROGRESS); - if (investigator.getRole() != Role.INVESTIGATOR) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "User is not an investigator"); - } + Ticket savedTicket = ticketRepository.save(ticket); - TicketStatus oldStatus = ticket.getStatus(); - ticket.setInvestigator(investigator); - ticket.setStatus(TicketStatus.IN_PROGRESS); + auditService.log( + AuditAction.STATUS_CHANGED, + "status", + oldStatus.name(), + TicketStatus.IN_PROGRESS.name(), + savedTicket, + user); - Ticket savedTicket = ticketRepository.save(ticket); + auditService.log( + AuditAction.ASSIGNED, "investigator", null, investigator.getUsername(), savedTicket, user); + return ticketMapper.entityToViewDTO(savedTicket); + } - auditService.log( - AuditAction.STATUS_CHANGED, - "status", - oldStatus.name(), - TicketStatus.IN_PROGRESS.name(), - savedTicket, - user - ); + @Transactional + public TicketViewDTO unassignInvestigator(Long id) { - auditService.log( - AuditAction.ASSIGNED, - "investigator", - null, - investigator.getUsername(), - savedTicket, - user - ); + User user = requireCurrentUser(); + requireAdmin(user); + Ticket ticket = + ticketRepository + .findById(id) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ticket not found")); - return ticketMapper.entityToViewDTO(savedTicket); + if (ticket.getInvestigator() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No investigator assigned"); } - @Transactional - public TicketViewDTO unassignInvestigator(Long id) { - - User user = requireCurrentUser(); - requireAdmin(user); - - Ticket ticket = ticketRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, "Ticket not found")); - - if (ticket.getInvestigator() == null) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "No investigator assigned"); - } - - if (ticket.getStatus() != TicketStatus.IN_PROGRESS) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Must be IN_PROGRESS"); - } + if (ticket.getStatus() != TicketStatus.IN_PROGRESS) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Must be IN_PROGRESS"); + } - String oldInvestigator = ticket.getInvestigator().getUsername(); + String oldInvestigator = ticket.getInvestigator().getUsername(); - ticket.setInvestigator(null); - ticket.setStatus(TicketStatus.OPEN); + ticket.setInvestigator(null); + ticket.setStatus(TicketStatus.OPEN); - Ticket savedTicket = ticketRepository.save(ticket); + Ticket savedTicket = ticketRepository.save(ticket); - auditService.log( - AuditAction.UNASSIGNED, - "investigator", - oldInvestigator, - null, - savedTicket, - user - ); + auditService.log( + AuditAction.UNASSIGNED, "investigator", oldInvestigator, null, savedTicket, user); - return ticketMapper.entityToViewDTO(savedTicket); - } -} \ No newline at end of file + return ticketMapper.entityToViewDTO(savedTicket); + } +} diff --git a/src/main/java/org/example/alfs/services/UserService.java b/src/main/java/org/example/alfs/services/UserService.java index e7f5000..ea9e86a 100644 --- a/src/main/java/org/example/alfs/services/UserService.java +++ b/src/main/java/org/example/alfs/services/UserService.java @@ -1,22 +1,21 @@ package org.example.alfs.services; +import java.util.List; import org.example.alfs.entities.User; import org.example.alfs.enums.Role; import org.example.alfs.repositories.UserRepository; import org.springframework.stereotype.Service; -import java.util.List; - @Service public class UserService { - private final UserRepository userRepository; + private final UserRepository userRepository; - public UserService(UserRepository userRepository) { - this.userRepository = userRepository; - } + public UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } - public List getAllInvestigators() { - return userRepository.findByRole(Role.INVESTIGATOR); - } + public List getAllInvestigators() { + return userRepository.findByRole(Role.INVESTIGATOR); + } } diff --git a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java index a051f81..c54b6e1 100644 --- a/src/main/java/org/example/alfs/services/storage/MinioStorageService.java +++ b/src/main/java/org/example/alfs/services/storage/MinioStorageService.java @@ -5,67 +5,58 @@ import io.minio.MinioClient; import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; +import java.io.InputStream; +import java.util.UUID; import org.example.alfs.config.S3Properties; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import java.io.InputStream; -import java.util.UUID; - @Service public class MinioStorageService { - private final MinioClient minioClient; - private final S3Properties props; - - public MinioStorageService(MinioClient minioClient, S3Properties props) { - this.minioClient = minioClient; - this.props = props; - } + private final MinioClient minioClient; + private final S3Properties props; - /** - * Laddar upp en fil till S3/MinIO och returnerar dess objectKey. - */ - public String upload(MultipartFile file) throws Exception { - String fileName = file.getOriginalFilename(); - if (fileName == null || fileName.isBlank()) { - fileName = "file"; - } - String objectKey = UUID.randomUUID() + "/" + sanitize(fileName); + public MinioStorageService(MinioClient minioClient, S3Properties props) { + this.minioClient = minioClient; + this.props = props; + } - try (InputStream is = file.getInputStream()) { - String contentType = file.getContentType() != null ? file.getContentType() : "application/octet-stream"; - PutObjectArgs args = PutObjectArgs.builder() - .bucket(props.getBucket()) - .object(objectKey) - .contentType(contentType) - .stream(is, file.getSize(), -1) - .build(); - minioClient.putObject(args); - } - - return objectKey; + /** Laddar upp en fil till S3/MinIO och returnerar dess objectKey. */ + public String upload(MultipartFile file) throws Exception { + String fileName = file.getOriginalFilename(); + if (fileName == null || fileName.isBlank()) { + fileName = "file"; } - - public GetObjectResponse download(String objectKey) throws Exception { - return minioClient.getObject( - GetObjectArgs.builder() - .bucket(props.getBucket()) - .object(objectKey) - .build() - ); + String objectKey = UUID.randomUUID() + "/" + sanitize(fileName); + + try (InputStream is = file.getInputStream()) { + String contentType = + file.getContentType() != null ? file.getContentType() : "application/octet-stream"; + PutObjectArgs args = + PutObjectArgs.builder() + .bucket(props.getBucket()) + .object(objectKey) + .contentType(contentType) + .stream(is, file.getSize(), -1) + .build(); + minioClient.putObject(args); } - public void delete(String objectKey) throws Exception { - minioClient.removeObject( - RemoveObjectArgs.builder() - .bucket(props.getBucket()) - .object(objectKey) - .build() - ); - } + return objectKey; + } - private String sanitize(String name) { - return name.replace("\\", "_").replace("/", "_"); - } + public GetObjectResponse download(String objectKey) throws Exception { + return minioClient.getObject( + GetObjectArgs.builder().bucket(props.getBucket()).object(objectKey).build()); + } + + public void delete(String objectKey) throws Exception { + minioClient.removeObject( + RemoveObjectArgs.builder().bucket(props.getBucket()).object(objectKey).build()); + } + + private String sanitize(String name) { + return name.replace("\\", "_").replace("/", "_"); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 76e45ae..7c5444f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,7 @@ gg.jte.development-mode=true spring.application.name=alfs +server.address=0.0.0.0 +spring.profiles.active=demo #Disable whitelabel spring.web.error.whitelabel.enabled=false diff --git a/src/test/java/org/example/alfs/AlfsApplicationTests.java b/src/test/java/org/example/alfs/AlfsApplicationTests.java index 58c6a03..4751ae0 100644 --- a/src/test/java/org/example/alfs/AlfsApplicationTests.java +++ b/src/test/java/org/example/alfs/AlfsApplicationTests.java @@ -6,8 +6,6 @@ @SpringBootTest class AlfsApplicationTests { - @Test - void contextLoads() { - } - + @Test + void contextLoads() {} } diff --git a/src/test/java/org/example/alfs/ArchitectureTest.java b/src/test/java/org/example/alfs/ArchitectureTest.java index fc4d8af..2435d1c 100644 --- a/src/test/java/org/example/alfs/ArchitectureTest.java +++ b/src/test/java/org/example/alfs/ArchitectureTest.java @@ -1,116 +1,122 @@ package org.example.alfs; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; import org.junit.jupiter.api.Test; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; - - /** * Architecture Test * - * Verifies that the project follows a clean layered architecture - * and enforces separation of concerns using ArchUnit. + *

Verifies that the project follows a clean layered architecture and enforces separation of + * concerns using ArchUnit. */ - class ArchitectureTest { - private final JavaClasses classes = - new ClassFileImporter().importPackages("org.example.alfs"); - - /** - * Layer Rules - * - * Enforces a clean layered architecture: - * Controller → Service → Repository. - * Controllers must not access repositories directly, - * and services must not depend on controllers. - */ - - @Test - void controllers_should_only_access_services_not_repositories() { - noClasses() - .that().resideInAPackage("..controllers..") - .should().dependOnClassesThat() - .resideInAPackage("..repositories..") - .check(classes); - } - - @Test - void services_should_not_depend_on_controllers() { - noClasses() - .that().resideInAPackage("..services..") - .should().dependOnClassesThat() - .resideInAPackage("..controllers..") - .check(classes); - } - - /** - * Repository Access Rule - * - * Controllers, DTOs and mappers must not access repositories directly. - * Repository access should go through the service layer. - */ - @Test - void controllers_dto_and_mapper_should_not_access_repositories() { - noClasses() - .that().resideInAnyPackage("..controllers..", "..dto..", "..mapper..") - .should().dependOnClassesThat() - .resideInAPackage("..repositories..") - .check(classes); - } - - /** - * Package Rules - * - * Classes should be in correct packages. - */ - - @Test - void controllers_should_be_in_controller_package() { - classes() - .that().haveSimpleNameEndingWith("Controller") - .should().resideInAPackage("..controllers..") - .check(classes); - } - - /** - * Service Package Rule - * - * Services should reside in the services package. - * An exception is made for JwtService, which is located in the security package. - */ - @Test - void services_should_be_in_services_package() { - classes() - .that().haveSimpleNameEndingWith("Service") - .should().resideInAnyPackage("..services..", "..security..") - .check(classes); - } - - @Test - void repositories_should_be_in_repositories_package() { - classes() - .that().haveSimpleNameEndingWith("Repository") - .should().resideInAPackage("..repositories..") - .check(classes); - } - - /** - * Dtos should not depend on entities - * - * Entities represent database structure and may change due to internal requirements, - * while DTOs define what is exposed externally. If DTOs depend on entities, - * changes in the database can unintentionally affect the API. - */ - - @Test - void dto_should_not_depend_on_entities() { - noClasses() - .that().resideInAPackage("..dto..") - .should().dependOnClassesThat() - .resideInAPackage("..entities..") - .check(classes); - } -} \ No newline at end of file + private final JavaClasses classes = new ClassFileImporter().importPackages("org.example.alfs"); + + /** + * Layer Rules + * + *

Enforces a clean layered architecture: Controller → Service → Repository. Controllers must + * not access repositories directly, and services must not depend on controllers. + */ + @Test + void controllers_should_only_access_services_not_repositories() { + noClasses() + .that() + .resideInAPackage("..controllers..") + .should() + .dependOnClassesThat() + .resideInAPackage("..repositories..") + .check(classes); + } + + @Test + void services_should_not_depend_on_controllers() { + noClasses() + .that() + .resideInAPackage("..services..") + .should() + .dependOnClassesThat() + .resideInAPackage("..controllers..") + .check(classes); + } + + /** + * Repository Access Rule + * + *

Controllers, DTOs and mappers must not access repositories directly. Repository access + * should go through the service layer. + */ + @Test + void controllers_dto_and_mapper_should_not_access_repositories() { + noClasses() + .that() + .resideInAnyPackage("..controllers..", "..dto..", "..mapper..") + .should() + .dependOnClassesThat() + .resideInAPackage("..repositories..") + .check(classes); + } + + /** + * Package Rules + * + *

Classes should be in correct packages. + */ + @Test + void controllers_should_be_in_controller_package() { + classes() + .that() + .haveSimpleNameEndingWith("Controller") + .should() + .resideInAPackage("..controllers..") + .check(classes); + } + + /** + * Service Package Rule + * + *

Services should reside in the services package. An exception is made for JwtService, which + * is located in the security package. + */ + @Test + void services_should_be_in_services_package() { + classes() + .that() + .haveSimpleNameEndingWith("Service") + .should() + .resideInAnyPackage("..services..", "..security..") + .check(classes); + } + + @Test + void repositories_should_be_in_repositories_package() { + classes() + .that() + .haveSimpleNameEndingWith("Repository") + .should() + .resideInAPackage("..repositories..") + .check(classes); + } + + /** + * Dtos should not depend on entities + * + *

Entities represent database structure and may change due to internal requirements, while + * DTOs define what is exposed externally. If DTOs depend on entities, changes in the database can + * unintentionally affect the API. + */ + @Test + void dto_should_not_depend_on_entities() { + noClasses() + .that() + .resideInAPackage("..dto..") + .should() + .dependOnClassesThat() + .resideInAPackage("..entities..") + .check(classes); + } +} diff --git a/src/test/java/org/example/alfs/controllers/TicketControllerIT.java b/src/test/java/org/example/alfs/controllers/TicketControllerIT.java index 83cfdda..c9799d4 100644 --- a/src/test/java/org/example/alfs/controllers/TicketControllerIT.java +++ b/src/test/java/org/example/alfs/controllers/TicketControllerIT.java @@ -1,5 +1,11 @@ package org.example.alfs.controllers; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + import org.example.alfs.dto.ticket.TicketCreateDTO; import org.example.alfs.entities.User; import org.example.alfs.enums.Role; @@ -17,203 +23,202 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - @SpringBootTest @AutoConfigureMockMvc @Transactional @DisplayName("TicketController Integration Tests") class TicketControllerIT { - @Autowired - private MockMvc mockMvc; - - @Autowired - private TicketService ticketService; - @Autowired - private UserRepository userRepository; - @Autowired - private TicketRepository ticketRepository; - - private Long ticketId; - private User admin; - private User investigator; - private User reporter; - - @BeforeEach - void setUp() { - admin = new User(); - admin.setRole(Role.ADMIN); - admin.setUsername("admin"); - admin.setPasswordHash("hashed-password"); - admin = userRepository.save(admin); - - investigator = new User(); - investigator.setRole(Role.INVESTIGATOR); - investigator.setUsername("investigator"); - investigator.setPasswordHash("hashed-password"); - investigator = userRepository.save(investigator); - - reporter = new User(); - reporter.setRole(Role.REPORTER); - reporter.setUsername("reporter"); - reporter.setPasswordHash("hashed-password"); - reporter = userRepository.save(reporter); - - TicketCreateDTO dto = new TicketCreateDTO(); - dto.setTitle("Test"); - dto.setDescription("Test"); - ticketId = ticketService.createNewTicket(dto).getId(); + @Autowired private MockMvc mockMvc; + + @Autowired private TicketService ticketService; + @Autowired private UserRepository userRepository; + @Autowired private TicketRepository ticketRepository; + + private Long ticketId; + private User admin; + private User investigator; + private User reporter; + + @BeforeEach + void setUp() { + admin = new User(); + admin.setRole(Role.ADMIN); + admin.setUsername("admin"); + admin.setPasswordHash("hashed-password"); + admin = userRepository.save(admin); + + investigator = new User(); + investigator.setRole(Role.INVESTIGATOR); + investigator.setUsername("investigator"); + investigator.setPasswordHash("hashed-password"); + investigator = userRepository.save(investigator); + + reporter = new User(); + reporter.setRole(Role.REPORTER); + reporter.setUsername("reporter"); + reporter.setPasswordHash("hashed-password"); + reporter = userRepository.save(reporter); + + TicketCreateDTO dto = new TicketCreateDTO(); + dto.setTitle("Test"); + dto.setDescription("Test"); + ticketId = ticketService.createNewTicket(dto).getId(); + } + + @Nested + @DisplayName("Anonymous Reporter") + class AnonymousReporter { + + @Test + @DisplayName("Anonymous reporter can access create form") + void anonymousReporter_canAccessCreateForm() throws Exception { + mockMvc + .perform(get("/tickets/create")) + .andExpect(status().isOk()) + .andExpect(view().name("create")) + .andExpect(model().attributeExists("ticket")); + } + + @Test + @DisplayName("Anonymous reporter can view a created ticket with valid token") + void anonymousReporter_validToken_returnsView() throws Exception { + var ticket = ticketRepository.findById(ticketId).orElseThrow(); + + mockMvc + .perform(get("/tickets/token/" + ticket.getReporterToken())) + .andExpect(status().isOk()) + .andExpect(view().name("view")) + .andExpect(model().attributeExists("ticket")) + .andExpect(model().attributeExists("comments")) + .andExpect(model().attributeExists("attachments")) + .andExpect(model().attributeExists("auditLogs")) + .andExpect(model().attributeExists("investigators")) + .andExpect(model().attributeExists("accessToken")); } - @Nested - @DisplayName("Anonymous Reporter") - class AnonymousReporter { - - @Test - @DisplayName("Anonymous reporter can access create form") - void anonymousReporter_canAccessCreateForm() throws Exception { - mockMvc.perform(get("/tickets/create")) - .andExpect(status().isOk()) - .andExpect(view().name("create")) - .andExpect(model().attributeExists("ticket")); - } - - @Test - @DisplayName("Anonymous reporter can view a created ticket with valid token") - void anonymousReporter_validToken_returnsView() throws Exception { - var ticket = ticketRepository.findById(ticketId).orElseThrow(); - - mockMvc.perform(get("/tickets/token/" + ticket.getReporterToken())) - .andExpect(status().isOk()) - .andExpect(view().name("view")) - .andExpect(model().attributeExists("ticket")) - .andExpect(model().attributeExists("comments")) - .andExpect(model().attributeExists("attachments")) - .andExpect(model().attributeExists("auditLogs")) - .andExpect(model().attributeExists("investigators")) - .andExpect(model().attributeExists("accessToken")); - } - - @Test - @DisplayName("Anonymous reporter is redirected when token is invalid") - void anonymousReporter_invalidToken_redirectsToLogin() throws Exception { - mockMvc.perform(get("/tickets/token/invalid-token")) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/login?tokenError=true")); - } - - @Test - @DisplayName("Anonymous reporter submits valid ticket and is redirected to ticket-created") - void anonymousReporter_validPost_redirectsToTicketCreated() throws Exception { - mockMvc.perform(post("/tickets/create") - .param("title", "Test title") - .param("description", "Test description") - .with(csrf())) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrlPattern("/tickets/ticket-created?token=*")); - } - - @Test - @DisplayName("Anonymous reporter submits blank form and sees create page again") - void anonymousReporter_blankPost_returnsCreateForm() throws Exception { - mockMvc.perform(post("/tickets/create") - .param("title", "") - .param("description", "") - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(view().name("create")) - .andExpect(model().attributeHasFieldErrors("ticket", "title", "description")); - } + @Test + @DisplayName("Anonymous reporter is redirected when token is invalid") + void anonymousReporter_invalidToken_redirectsToLogin() throws Exception { + mockMvc + .perform(get("/tickets/token/invalid-token")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?tokenError=true")); } - @Nested - @DisplayName("Authenticated reporter") - class AuthenticatedReporter { - - @Test - @WithMockUser(username = "reporter", roles = "REPORTER") - @DisplayName("Reporter can view their own tickets") - void reporter_canViewOwnTickets() throws Exception { - mockMvc.perform(get("/tickets/my")) - .andExpect(status().isOk()) - .andExpect(view().name("my-tickets")) - .andExpect(model().attributeExists("tickets")); - } + @Test + @DisplayName("Anonymous reporter submits valid ticket and is redirected to ticket-created") + void anonymousReporter_validPost_redirectsToTicketCreated() throws Exception { + mockMvc + .perform( + post("/tickets/create") + .param("title", "Test title") + .param("description", "Test description") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern("/tickets/ticket-created?token=*")); + } + + @Test + @DisplayName("Anonymous reporter submits blank form and sees create page again") + void anonymousReporter_blankPost_returnsCreateForm() throws Exception { + mockMvc + .perform(post("/tickets/create").param("title", "").param("description", "").with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("create")) + .andExpect(model().attributeHasFieldErrors("ticket", "title", "description")); + } + } + + @Nested + @DisplayName("Authenticated reporter") + class AuthenticatedReporter { + + @Test + @WithMockUser(username = "reporter", roles = "REPORTER") + @DisplayName("Reporter can view their own tickets") + void reporter_canViewOwnTickets() throws Exception { + mockMvc + .perform(get("/tickets/my")) + .andExpect(status().isOk()) + .andExpect(view().name("my-tickets")) + .andExpect(model().attributeExists("tickets")); } + } + + @Nested + @DisplayName("Authenticated investigator") + class AuthenticatedInvestigator { + + @Test + @WithMockUser(username = "investigator", roles = "INVESTIGATOR") + @DisplayName("Investigator can view their assigned tickets") + void investigator_canViewAssignedTickets() throws Exception { + mockMvc + .perform(get("/tickets/assigned")) + .andExpect(status().isOk()) + .andExpect(view().name("assigned-tickets")) + .andExpect(model().attributeExists("tickets")); + } + } + + @Nested + @DisplayName("Admin") + class Admin { + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + @DisplayName("Admin can view ticket by id") + void admin_canViewTicketById() throws Exception { + mockMvc + .perform(get("/tickets/{id}", ticketId)) + .andExpect(status().isOk()) + .andExpect(view().name("view")) + .andExpect(model().attributeExists("ticket")) + .andExpect(model().attributeExists("comments")) + .andExpect(model().attributeExists("attachments")) + .andExpect(model().attributeExists("auditLogs")) + .andExpect(model().attributeExists("investigators")); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + @DisplayName("Admin can assign an investigator to a ticket") + void admin_canAssignInvestigator() throws Exception { + mockMvc + .perform( + post("/tickets/{id}/assign", ticketId) + .param("investigatorId", investigator.getId().toString()) + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/tickets/" + ticketId)); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + @DisplayName("Admin can update status on a ticket") + void admin_canUpdateTicketStatus() throws Exception { + ticketService.assignInvestigator(ticketId, investigator.getId()); - @Nested - @DisplayName("Authenticated investigator") - class AuthenticatedInvestigator { - - @Test - @WithMockUser(username = "investigator", roles = "INVESTIGATOR") - @DisplayName("Investigator can view their assigned tickets") - void investigator_canViewAssignedTickets() throws Exception { - mockMvc.perform(get("/tickets/assigned")) - .andExpect(status().isOk()) - .andExpect(view().name("assigned-tickets")) - .andExpect(model().attributeExists("tickets")); - } + mockMvc + .perform(post("/tickets/{id}/status", ticketId).param("status", "RESOLVED").with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/tickets/" + ticketId)); } - @Nested - @DisplayName("Admin") - class Admin { - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - @DisplayName("Admin can view ticket by id") - void admin_canViewTicketById() throws Exception { - mockMvc.perform(get("/tickets/{id}", ticketId)) - .andExpect(status().isOk()) - .andExpect(view().name("view")) - .andExpect(model().attributeExists("ticket")) - .andExpect(model().attributeExists("comments")) - .andExpect(model().attributeExists("attachments")) - .andExpect(model().attributeExists("auditLogs")) - .andExpect(model().attributeExists("investigators")); - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - @DisplayName("Admin can assign an investigator to a ticket") - void admin_canAssignInvestigator() throws Exception { - mockMvc.perform(post("/tickets/{id}/assign", ticketId) - .param("investigatorId", investigator.getId().toString()) - .with(csrf())) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/tickets/" + ticketId)); - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - @DisplayName("Admin can update status on a ticket") - void admin_canUpdateTicketStatus() throws Exception { - ticketService.assignInvestigator(ticketId, investigator.getId()); - - mockMvc.perform(post("/tickets/{id}/status", ticketId) - .param("status", "RESOLVED") - .with(csrf())) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/tickets/" + ticketId)); - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - @DisplayName("Admin cannot perform invalid status transition") - void admin_cannotPerformInvalidStatusTransition() throws Exception { - var ticket = ticketRepository.findById(ticketId).orElseThrow(); - assertEquals(org.example.alfs.enums.TicketStatus.OPEN, ticket.getStatus()); - - mockMvc.perform(post("/tickets/{id}/status", ticketId) - .param("status", "RESOLVED") // Transition OPEN -> RESOLVED is invalid - .with(csrf())) - .andExpect(status().isBadRequest()); - } + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + @DisplayName("Admin cannot perform invalid status transition") + void admin_cannotPerformInvalidStatusTransition() throws Exception { + var ticket = ticketRepository.findById(ticketId).orElseThrow(); + assertEquals(org.example.alfs.enums.TicketStatus.OPEN, ticket.getStatus()); + + mockMvc + .perform( + post("/tickets/{id}/status", ticketId) + .param("status", "RESOLVED") // Transition OPEN -> RESOLVED is invalid + .with(csrf())) + .andExpect(status().isBadRequest()); } -} \ No newline at end of file + } +} diff --git a/src/test/java/org/example/alfs/services/AttachmentServiceTest.java b/src/test/java/org/example/alfs/services/AttachmentServiceTest.java index d6bbb0c..2b74e87 100644 --- a/src/test/java/org/example/alfs/services/AttachmentServiceTest.java +++ b/src/test/java/org/example/alfs/services/AttachmentServiceTest.java @@ -1,5 +1,10 @@ package org.example.alfs.services; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; import org.example.alfs.entities.Attachment; import org.example.alfs.entities.Ticket; import org.example.alfs.entities.User; @@ -20,238 +25,234 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - @DisplayName("AttachmentService Test") @ExtendWith(MockitoExtension.class) class AttachmentServiceTest { - @Mock - private MinioStorageService storageService; - @Mock - private AttachmentRepository attachmentRepository; - @Mock - private TicketRepository ticketRepository; - @Mock - private AuditService auditService; - @Mock - private SecurityUtils securityUtils; - - @InjectMocks - private AttachmentService attachmentService; - - private Ticket ticket; - private User admin; - private User investigator; - private User reporter; - private MultipartFile file; - - @BeforeEach - void setUp() { - admin = new User(); - admin.setId(100L); - admin.setRole(Role.ADMIN); - - investigator = new User(); - investigator.setId(200L); - investigator.setRole(Role.INVESTIGATOR); - - reporter = new User(); - reporter.setId(300L); - reporter.setRole(Role.REPORTER); - - ticket = new Ticket(); - ticket.setId(10L); - ticket.setReporter(reporter); - ticket.setInvestigator(investigator); - ticket.setReporterToken("valid-token"); - - file = mock(MultipartFile.class); + @Mock private MinioStorageService storageService; + @Mock private AttachmentRepository attachmentRepository; + @Mock private TicketRepository ticketRepository; + @Mock private AuditService auditService; + @Mock private SecurityUtils securityUtils; + + @InjectMocks private AttachmentService attachmentService; + + private Ticket ticket; + private User admin; + private User investigator; + private User reporter; + private MultipartFile file; + + @BeforeEach + void setUp() { + admin = new User(); + admin.setId(100L); + admin.setRole(Role.ADMIN); + + investigator = new User(); + investigator.setId(200L); + investigator.setRole(Role.INVESTIGATOR); + + reporter = new User(); + reporter.setId(300L); + reporter.setRole(Role.REPORTER); + + ticket = new Ticket(); + ticket.setId(10L); + ticket.setReporter(reporter); + ticket.setInvestigator(investigator); + ticket.setReporterToken("valid-token"); + + file = mock(MultipartFile.class); + } + + @Nested + @DisplayName("uploadToTicket tests") + class UploadToTicketTest { + + @Test + @DisplayName("Authenticated reporter uploads to valid ticket") + void authenticatedReporter_withValidTicket_shouldUploadSuccessfully() throws Exception { + when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); + when(storageService.upload(file)).thenReturn("s3-key"); + when(file.getOriginalFilename()).thenReturn("report.pdf"); + + Attachment result = attachmentService.uploadToTicket(10L, file, reporter, null); + + assertThat(result.getFileName()).isEqualTo("report.pdf"); + assertThat(result.getS3Key()).isEqualTo("s3-key"); + assertThat(result.getUploadedBy()).isSameAs(reporter); + verify(attachmentRepository).save(any(Attachment.class)); + } + + @Test + @DisplayName("Anonymous reporter uploads to valid ticket") + void anonymousReporter_withValidToken_shouldUploadSuccessfully() throws Exception { + when(ticketRepository.findByReporterToken("valid-token")).thenReturn(Optional.of(ticket)); + when(storageService.upload(file)).thenReturn("s3-key"); + when(file.getOriginalFilename()).thenReturn("evidence.pdf"); + + Attachment result = attachmentService.uploadToTicket(10L, file, null, "valid-token"); + + assertThat(result.getFileName()).isEqualTo("evidence.pdf"); + verify(attachmentRepository).save(any(Attachment.class)); + } + + @Test + @DisplayName("Anonymous reporter with no token should throw Unauthorized") + void anonymousReporter_withMissingToken_throwsUnauthorized() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> attachmentService.uploadToTicket(10L, file, null, null)); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(storageService); + } + + @Test + @DisplayName("Anonymous reporter with blank token should throw Unauthorized") + void anonymousReporter_withBlankToken_throwsUnauthorized() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> attachmentService.uploadToTicket(10L, file, null, " ")); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(storageService); + } + + @Test + @DisplayName("Ticket not found should throw Not Found") + void ticketNotFound_throwsNotFound() { + when(ticketRepository.findById(11L)).thenReturn(Optional.empty()); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> attachmentService.uploadToTicket(11L, file, admin, null)); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("Null file name should fall back to default name") + void nullFileName_fallsBackToDefaultName() throws Exception { + when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); + when(storageService.upload(file)).thenReturn("s3-key"); + when(file.getOriginalFilename()).thenReturn(null); + + Attachment result = attachmentService.uploadToTicket(10L, file, admin, null); + + assertThat(result.getS3Key()).isEqualTo("s3-key"); + assertThat(result.getFileName()).isEqualTo("file"); + verify(attachmentRepository).save(any(Attachment.class)); + } + + @Test + @DisplayName("When save fails uploaded object should be deleted from storage") + void whenSaveFails_deletesUploadedObjectFromStorage() throws Exception { + when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); + when(storageService.upload(file)).thenReturn("s3-key"); + when(file.getOriginalFilename()).thenReturn("doc.pdf"); + doThrow(new RuntimeException("DB down")).when(attachmentRepository).save(any()); + + assertThrows( + RuntimeException.class, () -> attachmentService.uploadToTicket(10L, file, admin, null)); + + verify(storageService).delete("s3-key"); + } + } + + @Nested + @DisplayName("checkAccess tests") + class CheckAccessTest { + + @Test + @DisplayName("Admin always allowed") + void admin_alwaysAllowed() throws Exception { + when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); + when(storageService.upload(file)).thenReturn("s3-key"); + when(file.getOriginalFilename()).thenReturn("file.pdf"); + + assertDoesNotThrow(() -> attachmentService.uploadToTicket(10L, file, admin, null)); + } + + @Test + @DisplayName("Assigned investigator allowed") + void assignedInvestigator_allowed() throws Exception { + when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); + when(storageService.upload(file)).thenReturn("s3-key"); + when(file.getOriginalFilename()).thenReturn("f.pdf"); + + assertDoesNotThrow(() -> attachmentService.uploadToTicket(10L, file, investigator, null)); + } + + @Test + @DisplayName("Unassigned investigator denied") + void unassignedInvestigator_throwsForbidden() { + User otherInvestigator = new User(); + otherInvestigator.setId(201L); + otherInvestigator.setRole(Role.INVESTIGATOR); + + when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> attachmentService.uploadToTicket(10L, file, otherInvestigator, null)); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("Reporter who owns ticket allowed") + void reporterWhoOwnsTicket_allowed() throws Exception { + when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); + when(storageService.upload(file)).thenReturn("s3-key"); + when(file.getOriginalFilename()).thenReturn("f.pdf"); + + assertDoesNotThrow(() -> attachmentService.uploadToTicket(10L, file, reporter, null)); + } + + @Test + @DisplayName("Reporter who does not own ticket denied") + void reporterWhoDoesNotOwnTicket_throwsForbidden() { + User otherReporter = new User(); + otherReporter.setId(301L); + otherReporter.setRole(Role.REPORTER); + + when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> attachmentService.uploadToTicket(10L, file, otherReporter, null)); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); } - @Nested - @DisplayName("uploadToTicket tests") - class UploadToTicketTest { - - @Test - @DisplayName("Authenticated reporter uploads to valid ticket") - void authenticatedReporter_withValidTicket_shouldUploadSuccessfully() throws Exception { - when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); - when(storageService.upload(file)).thenReturn("s3-key"); - when(file.getOriginalFilename()).thenReturn("report.pdf"); - - Attachment result = attachmentService.uploadToTicket(10L, file, reporter, null); - - assertThat(result.getFileName()).isEqualTo("report.pdf"); - assertThat(result.getS3Key()).isEqualTo("s3-key"); - assertThat(result.getUploadedBy()).isSameAs(reporter); - verify(attachmentRepository).save(any(Attachment.class)); - } - - @Test - @DisplayName("Anonymous reporter uploads to valid ticket") - void anonymousReporter_withValidToken_shouldUploadSuccessfully() throws Exception { - when(ticketRepository.findByReporterToken("valid-token")).thenReturn(Optional.of(ticket)); - when(storageService.upload(file)).thenReturn("s3-key"); - when(file.getOriginalFilename()).thenReturn("evidence.pdf"); - - Attachment result = attachmentService.uploadToTicket(10L, file, null, "valid-token"); - - assertThat(result.getFileName()).isEqualTo("evidence.pdf"); - verify(attachmentRepository).save(any(Attachment.class)); - } - - @Test - @DisplayName("Anonymous reporter with no token should throw Unauthorized") - void anonymousReporter_withMissingToken_throwsUnauthorized() { - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> attachmentService.uploadToTicket(10L, file, null, null)); - - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - verifyNoInteractions(storageService); - } - - @Test - @DisplayName("Anonymous reporter with blank token should throw Unauthorized") - void anonymousReporter_withBlankToken_throwsUnauthorized() { - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> attachmentService.uploadToTicket(10L, file, null, " ")); - - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - verifyNoInteractions(storageService); - } - - @Test - @DisplayName("Ticket not found should throw Not Found") - void ticketNotFound_throwsNotFound() { - when(ticketRepository.findById(11L)).thenReturn(Optional.empty()); - - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> attachmentService.uploadToTicket(11L, file, admin, null)); - - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - } - - @Test - @DisplayName("Null file name should fall back to default name") - void nullFileName_fallsBackToDefaultName() throws Exception { - when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); - when(storageService.upload(file)).thenReturn("s3-key"); - when(file.getOriginalFilename()).thenReturn(null); - - Attachment result = attachmentService.uploadToTicket(10L, file, admin, null); - - assertThat(result.getS3Key()).isEqualTo("s3-key"); - assertThat(result.getFileName()).isEqualTo("file"); - verify(attachmentRepository).save(any(Attachment.class)); - } - - @Test - @DisplayName("When save fails uploaded object should be deleted from storage") - void whenSaveFails_deletesUploadedObjectFromStorage() throws Exception { - when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); - when(storageService.upload(file)).thenReturn("s3-key"); - when(file.getOriginalFilename()).thenReturn("doc.pdf"); - doThrow(new RuntimeException("DB down")).when(attachmentRepository).save(any()); - - assertThrows(RuntimeException.class, - () -> attachmentService.uploadToTicket(10L, file, admin, null)); - - verify(storageService).delete("s3-key"); - } + @Test + @DisplayName("Anonymous reporter with valid token allowed") + void anonymousReporter_withCorrectToken_allowed() throws Exception { + when(ticketRepository.findByReporterToken("valid-token")).thenReturn(Optional.of(ticket)); + when(storageService.upload(file)).thenReturn("s3-key"); + when(file.getOriginalFilename()).thenReturn("f.pdf"); + + assertDoesNotThrow(() -> attachmentService.uploadToTicket(10L, file, null, "valid-token")); } - @Nested - @DisplayName("checkAccess tests") - class CheckAccessTest { - - @Test - @DisplayName("Admin always allowed") - void admin_alwaysAllowed() throws Exception { - when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); - when(storageService.upload(file)).thenReturn("s3-key"); - when(file.getOriginalFilename()).thenReturn("file.pdf"); - - assertDoesNotThrow( - () -> attachmentService.uploadToTicket(10L, file, admin, null)); - } - - @Test - @DisplayName("Assigned investigator allowed") - void assignedInvestigator_allowed() throws Exception { - when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); - when(storageService.upload(file)).thenReturn("s3-key"); - when(file.getOriginalFilename()).thenReturn("f.pdf"); - - assertDoesNotThrow(() -> - attachmentService.uploadToTicket(10L, file, investigator, null)); - } - - @Test - @DisplayName("Unassigned investigator denied") - void unassignedInvestigator_throwsForbidden() { - User otherInvestigator = new User(); - otherInvestigator.setId(201L); - otherInvestigator.setRole(Role.INVESTIGATOR); - - when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); - - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> attachmentService.uploadToTicket(10L, file, otherInvestigator, null)); - - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - } - - @Test - @DisplayName("Reporter who owns ticket allowed") - void reporterWhoOwnsTicket_allowed() throws Exception { - when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); - when(storageService.upload(file)).thenReturn("s3-key"); - when(file.getOriginalFilename()).thenReturn("f.pdf"); - - assertDoesNotThrow(() -> - attachmentService.uploadToTicket(10L, file, reporter, null)); - } - - @Test - @DisplayName("Reporter who does not own ticket denied") - void reporterWhoDoesNotOwnTicket_throwsForbidden() { - User otherReporter = new User(); - otherReporter.setId(301L); - otherReporter.setRole(Role.REPORTER); - - when(ticketRepository.findById(10L)).thenReturn(Optional.of(ticket)); - - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> attachmentService.uploadToTicket(10L, file, otherReporter, null)); - - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); - } - - @Test - @DisplayName("Anonymous reporter with valid token allowed") - void anonymousReporter_withCorrectToken_allowed() throws Exception { - when(ticketRepository.findByReporterToken("valid-token")).thenReturn(Optional.of(ticket)); - when(storageService.upload(file)).thenReturn("s3-key"); - when(file.getOriginalFilename()).thenReturn("f.pdf"); - - assertDoesNotThrow(() -> - attachmentService.uploadToTicket(10L, file, null, "valid-token")); - } - - @Test - @DisplayName("Anonymous reporter with invalid token should return Not Found") - void anonymousReporter_withWrongToken_throwsNotFound() { - when(ticketRepository.findByReporterToken("wrong-token")).thenReturn(Optional.empty()); - - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> attachmentService.uploadToTicket(10L, file, null, "wrong-token")); - - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - } + @Test + @DisplayName("Anonymous reporter with invalid token should return Not Found") + void anonymousReporter_withWrongToken_throwsNotFound() { + when(ticketRepository.findByReporterToken("wrong-token")).thenReturn(Optional.empty()); + + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> attachmentService.uploadToTicket(10L, file, null, "wrong-token")); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } -} \ No newline at end of file + } +} diff --git a/src/test/java/org/example/alfs/services/AuthServiceTest.java b/src/test/java/org/example/alfs/services/AuthServiceTest.java index 09ca628..416a4a5 100644 --- a/src/test/java/org/example/alfs/services/AuthServiceTest.java +++ b/src/test/java/org/example/alfs/services/AuthServiceTest.java @@ -1,5 +1,11 @@ package org.example.alfs.services; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; import org.example.alfs.dto.auth.SignupRequestDTO; import org.example.alfs.entities.User; import org.example.alfs.enums.Role; @@ -16,127 +22,118 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.server.ResponseStatusException; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) class AuthServiceTest { - @Mock - private UserRepository userRepository; - @Mock - private PasswordEncoder passwordEncoder; - - @InjectMocks - private AuthService authService; - - @Nested - @DisplayName("Login tests") - class Login { - - @Test - @DisplayName("User not found should throw Unauthorized") - void whenUserNotFound_throwsUnauthorized() { - // Arrange - when(userRepository.findByUsername("no-user")) - .thenReturn(Optional.empty()); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> authService.login("no-user", "password")); - - // Assert - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - @DisplayName("Wrong password should throw Unauthorized") - void whenWrongPassword_throwsUnauthorized() { - // Arrange - User user = new User(); - user.setPasswordHash("hashed-password"); - - when(userRepository.findByUsername("username")).thenReturn(Optional.of(user)); - when(passwordEncoder.matches("wrong-password", "hashed-password")).thenReturn(false); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> authService.login("username", "wrong-password")); - - // Assert - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - } - - @Test - @DisplayName("Correct credentials should return user") - void whenCredentialsCorrect_returnsUser() { - // Arrange - User user = new User(); - user.setUsername("username"); - user.setPasswordHash("hashed-password"); - - when(userRepository.findByUsername("username")).thenReturn(Optional.of(user)); - when(passwordEncoder.matches("correct-password", "hashed-password")).thenReturn(true); - - // Act - User result = authService.login("username", "correct-password"); - - // Assert - assertThat(result).isSameAs(user); - assertThat(result.getUsername()).isEqualTo("username"); - assertThat(result.getPasswordHash()).isEqualTo("hashed-password"); - } + @Mock private UserRepository userRepository; + @Mock private PasswordEncoder passwordEncoder; + + @InjectMocks private AuthService authService; + + @Nested + @DisplayName("Login tests") + class Login { + + @Test + @DisplayName("User not found should throw Unauthorized") + void whenUserNotFound_throwsUnauthorized() { + // Arrange + when(userRepository.findByUsername("no-user")).thenReturn(Optional.empty()); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, () -> authService.login("no-user", "password")); + + // Assert + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("Wrong password should throw Unauthorized") + void whenWrongPassword_throwsUnauthorized() { + // Arrange + User user = new User(); + user.setPasswordHash("hashed-password"); + + when(userRepository.findByUsername("username")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrong-password", "hashed-password")).thenReturn(false); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, () -> authService.login("username", "wrong-password")); + + // Assert + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("Correct credentials should return user") + void whenCredentialsCorrect_returnsUser() { + // Arrange + User user = new User(); + user.setUsername("username"); + user.setPasswordHash("hashed-password"); + + when(userRepository.findByUsername("username")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("correct-password", "hashed-password")).thenReturn(true); + + // Act + User result = authService.login("username", "correct-password"); + + // Assert + assertThat(result).isSameAs(user); + assertThat(result.getUsername()).isEqualTo("username"); + assertThat(result.getPasswordHash()).isEqualTo("hashed-password"); } + } + + @Nested + @DisplayName("Signup tests") + class Signup { + + @Test + @DisplayName("Username already taken should throw Bad Request") + void whenUsernameTaken_throwsBadRequest() { + // Arrange + when(userRepository.findByUsername("username")).thenReturn(Optional.of(new User())); + + SignupRequestDTO request = new SignupRequestDTO(); + request.setUsername("username"); + request.setPassword("password"); + + // Act + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> authService.signup(request)); + + // Assert + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(ex.getReason()).isEqualTo("Username already exists"); + } + + @Test + @DisplayName("Username available should save user with hashed password and reporter role") + void whenUsernameAvailable_savesUserWithHashedPasswordAndReporterRole() { + // Arrange + when(userRepository.findByUsername("username")).thenReturn(Optional.empty()); + when(passwordEncoder.encode("password")).thenReturn("hashed-password"); + + SignupRequestDTO request = new SignupRequestDTO(); + request.setUsername("username"); + request.setPassword("password"); + + authService.signup(request); + + // Act + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); - @Nested - @DisplayName("Signup tests") - class Signup { - - @Test - @DisplayName("Username already taken should throw Bad Request") - void whenUsernameTaken_throwsBadRequest() { - // Arrange - when(userRepository.findByUsername("username")).thenReturn(Optional.of(new User())); - - SignupRequestDTO request = new SignupRequestDTO(); - request.setUsername("username"); - request.setPassword("password"); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, - () -> authService.signup(request)); - - // Assert - assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(ex.getReason()).isEqualTo("Username already exists"); - } - - @Test - @DisplayName("Username available should save user with hashed password and reporter role") - void whenUsernameAvailable_savesUserWithHashedPasswordAndReporterRole() { - // Arrange - when(userRepository.findByUsername("username")).thenReturn(Optional.empty()); - when(passwordEncoder.encode("password")).thenReturn("hashed-password"); - - SignupRequestDTO request = new SignupRequestDTO(); - request.setUsername("username"); - request.setPassword("password"); - - authService.signup(request); - - // Act - ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); - - // Assert - verify(userRepository).save(captor.capture()); - User saved = captor.getValue(); - assertThat(saved.getUsername()).isEqualTo("username"); - assertThat(saved.getPasswordHash()).isEqualTo("hashed-password"); - assertThat(saved.getRole()).isEqualTo(Role.REPORTER); - } + // Assert + verify(userRepository).save(captor.capture()); + User saved = captor.getValue(); + assertThat(saved.getUsername()).isEqualTo("username"); + assertThat(saved.getPasswordHash()).isEqualTo("hashed-password"); + assertThat(saved.getRole()).isEqualTo(Role.REPORTER); } -} \ No newline at end of file + } +} diff --git a/src/test/java/org/example/alfs/services/TicketCommentServiceTest.java b/src/test/java/org/example/alfs/services/TicketCommentServiceTest.java index 3cc5ae9..0419619 100644 --- a/src/test/java/org/example/alfs/services/TicketCommentServiceTest.java +++ b/src/test/java/org/example/alfs/services/TicketCommentServiceTest.java @@ -1,5 +1,11 @@ package org.example.alfs.services; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; import org.example.alfs.dto.comment.CommentCreateDTO; import org.example.alfs.dto.comment.CommentViewDTO; import org.example.alfs.entities.Ticket; @@ -20,430 +26,440 @@ import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - @DisplayName("TicketCommentService Test") @ExtendWith(MockitoExtension.class) class TicketCommentServiceTest { - @Mock - TicketRepository ticketRepository; - @Mock - TicketCommentRepository ticketCommentRepository; - @Mock - TicketCommentMapper ticketCommentMapper; - - @InjectMocks - TicketCommentService ticketCommentService; - - private Ticket openTicketWithReporter(User reporter) { - Ticket t = new Ticket(); - t.setId(1L); - t.setStatus(TicketStatus.OPEN); - t.setReporter(reporter); - return t; + @Mock TicketRepository ticketRepository; + @Mock TicketCommentRepository ticketCommentRepository; + @Mock TicketCommentMapper ticketCommentMapper; + + @InjectMocks TicketCommentService ticketCommentService; + + private Ticket openTicketWithReporter(User reporter) { + Ticket t = new Ticket(); + t.setId(1L); + t.setStatus(TicketStatus.OPEN); + t.setReporter(reporter); + return t; + } + + private User adminUser() { + User u = new User(); + u.setId(100L); + u.setRole(Role.ADMIN); + return u; + } + + private User investigatorUser() { + User u = new User(); + u.setId(200L); + u.setRole(Role.INVESTIGATOR); + return u; + } + + private User reporterUser() { + User u = new User(); + u.setId(300L); + u.setRole(Role.REPORTER); + return u; + } + + private Ticket anonymousTicket(String token) { + Ticket t = new Ticket(); + t.setId(400L); + t.setStatus(TicketStatus.OPEN); + t.setReporterToken(token); + return t; + } + + private CommentCreateDTO dto(String message, boolean internalNote) { + CommentCreateDTO dto = new CommentCreateDTO(); + dto.setMessage(message); + dto.setInternalNote(internalNote); + return dto; + } + + @Nested + @DisplayName("addComment tests") + class AddCommentTests { + + @Test + @DisplayName("Admin can add a public comment") + void admin_canAddPublicComment() { + // Arrange + User admin = adminUser(); + Ticket ticket = openTicketWithReporter(reporterUser()); + CommentViewDTO expected = new CommentViewDTO(); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(expected); + + // Act + CommentViewDTO result = ticketCommentService.addComment(1L, dto("Hello", false), admin, null); + + // Assert + assertSame(expected, result); + verify(ticketCommentRepository).save(any()); + } + + @Test + @DisplayName("Admin can add an internal note") + void admin_canAddInternalNote() { + // Arrange + User admin = adminUser(); + Ticket ticket = openTicketWithReporter(reporterUser()); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); + + // Act + Assert + assertDoesNotThrow( + () -> ticketCommentService.addComment(1L, dto("Internal", true), admin, null)); + } + + @Test + @DisplayName("Assigned investigator can add a comment") + void assignedInvestigator_canAddComment() { + // Arrange + User investigator = investigatorUser(); + Ticket ticket = openTicketWithReporter(reporterUser()); + ticket.setInvestigator(investigator); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); + + // Act + Assert + assertDoesNotThrow( + () -> ticketCommentService.addComment(1L, dto("Note", false), investigator, null)); + } + + @Test + @DisplayName("Assigned investigator can add an internal note") + void assignedInvestigator_canAddInternalNote() { + // Arrange + User investigator = investigatorUser(); + Ticket ticket = openTicketWithReporter(reporterUser()); + ticket.setInvestigator(investigator); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); + + // Act + Assert + assertDoesNotThrow( + () -> ticketCommentService.addComment(1L, dto("Internal", true), investigator, null)); + } + + @Test + @DisplayName("Unassigned investigator should be forbidden") + void unassignedInvestigator_shouldThrowForbidden() { + // Arrange + User investigator = investigatorUser(); + Ticket ticket = openTicketWithReporter(reporterUser()); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketCommentService.addComment(1L, dto("Note", false), investigator, null)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + verify(ticketCommentRepository, never()).save(any()); + } + + @Test + @DisplayName("Reporter who owns the ticket can add a comment") + void owningReporter_canAddComment() { + // Arrange + User reporter = reporterUser(); + Ticket ticket = openTicketWithReporter(reporter); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); + + // Act + Assert + assertDoesNotThrow( + () -> ticketCommentService.addComment(1L, dto("My comment", false), reporter, null)); + } + + @Test + @DisplayName("Reporter cannot create an internal note") + void reporter_cannotCreateInternalNote() { + // Arrange + User reporter = reporterUser(); + Ticket ticket = openTicketWithReporter(reporter); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketCommentService.addComment(1L, dto("Secret", true), reporter, null)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + verify(ticketCommentRepository, never()).save(any()); + } + + @Test + @DisplayName("Reporter who does not own the ticket should be forbidden") + void nonOwningReporter_shouldThrowForbidden() { + // Arrange + User reporter = reporterUser(); + + User otherReporter = new User(); + otherReporter.setId(301L); + otherReporter.setRole(Role.REPORTER); + + Ticket ticket = openTicketWithReporter(otherReporter); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketCommentService.addComment(1L, dto("Note", false), reporter, null)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + verify(ticketCommentRepository, never()).save(any()); + } + + @Test + @DisplayName("Anonymous user with valid token can add a comment") + void anonymous_withValidToken_canAddComment() { + // Arrange + String token = "valid-token"; + Ticket ticket = anonymousTicket(token); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); + + // Act + Assert + assertDoesNotThrow( + () -> ticketCommentService.addComment(1L, dto("Anonymous comment", false), null, token)); + } + + @Test + @DisplayName("Anonymous user cannot create an internal note") + void anonymous_cannotCreateInternalNote() { + // Arrange + String token = "valid-token"; + Ticket ticket = anonymousTicket(token); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketCommentService.addComment(1L, dto("Secret", true), null, token)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + verify(ticketCommentRepository, never()).save(any()); + } + + @Test + @DisplayName("Anonymous user with wrong token should be forbidden") + void anonymous_withWrongToken_shouldThrowForbidden() { + // Arrange + Ticket ticket = anonymousTicket("correct-token"); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketCommentService.addComment(1L, dto("Note", false), null, "wrong-token")); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + verify(ticketCommentRepository, never()).save(any()); + } + + @Test + @DisplayName("Anonymous user with no token should be forbidden") + void anonymous_withNoToken_shouldThrowForbidden() { + // Arrange + Ticket ticket = anonymousTicket("valid-token"); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketCommentService.addComment(1L, dto("Note", false), null, null)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + verify(ticketCommentRepository, never()).save(any()); + } + + @Test + @DisplayName("Should throw Not Found when ticket does not exist") + void addComment_ticketNotFound_shouldThrowNotFound() { + // Arrange + when(ticketRepository.findById(1L)).thenReturn(Optional.empty()); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketCommentService.addComment(1L, dto("Hello", false), adminUser(), null)); + + // Assert + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + verify(ticketCommentRepository, never()).save(any()); } + } + + @Nested + @DisplayName("getComments tests") + class GetCommentsTests { + + @Test + @DisplayName("Admin sees all comments including internal notes") + void admin_seesAllComments() { + // Arrange + User admin = adminUser(); + Ticket ticket = openTicketWithReporter(reporterUser()); - private User adminUser() { - User u = new User(); - u.setId(100L); - u.setRole(Role.ADMIN); - return u; + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.findByTicketIdOrderByCreatedAtAsc(1L)).thenReturn(List.of()); + + // Act + ticketCommentService.getComments(1L, admin, null); + + // Assert + verify(ticketCommentRepository).findByTicketIdOrderByCreatedAtAsc(1L); + verify(ticketCommentRepository, never()) + .findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(any()); } - private User investigatorUser() { - User u = new User(); - u.setId(200L); - u.setRole(Role.INVESTIGATOR); - return u; + @Test + @DisplayName("Assigned investigator sees all comments including internal notes") + void assignedInvestigator_seesAllComments() { + // Arrange + User investigator = investigatorUser(); + Ticket ticket = openTicketWithReporter(reporterUser()); + ticket.setInvestigator(investigator); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.findByTicketIdOrderByCreatedAtAsc(1L)).thenReturn(List.of()); + + // Act + ticketCommentService.getComments(1L, investigator, null); + + // Assert + verify(ticketCommentRepository).findByTicketIdOrderByCreatedAtAsc(1L); } - private User reporterUser() { - User u = new User(); - u.setId(300L); - u.setRole(Role.REPORTER); - return u; + @Test + @DisplayName("Reporter only sees public comments") + void reporter_seesOnlyPublicComments() { + // Arrange + User reporter = reporterUser(); + Ticket ticket = openTicketWithReporter(reporter); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(1L)) + .thenReturn(List.of()); + + // Act + ticketCommentService.getComments(1L, reporter, null); + + // Assert + verify(ticketCommentRepository).findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(1L); + verify(ticketCommentRepository, never()).findByTicketIdOrderByCreatedAtAsc(any()); } - private Ticket anonymousTicket(String token) { - Ticket t = new Ticket(); - t.setId(400L); - t.setStatus(TicketStatus.OPEN); - t.setReporterToken(token); - return t; + @Test + @DisplayName("Anonymous reporter with valid token only sees public comments") + void anonymous_withValidToken_seesOnlyPublicComments() { + // Arrange + String token = "valid-token"; + Ticket ticket = anonymousTicket(token); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(1L)) + .thenReturn(List.of()); + + // Act + ticketCommentService.getComments(1L, null, token); + + // Assert + verify(ticketCommentRepository).findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(1L); + verify(ticketCommentRepository, never()).findByTicketIdOrderByCreatedAtAsc(any()); } - private CommentCreateDTO dto(String message, boolean internalNote) { - CommentCreateDTO dto = new CommentCreateDTO(); - dto.setMessage(message); - dto.setInternalNote(internalNote); - return dto; + @Test + @DisplayName("Anonymous reporter with wrong token should be forbidden") + void anonymous_withWrongToken_shouldThrowForbidden() { + // Arrange + Ticket ticket = anonymousTicket("correct-token"); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketCommentService.getComments(1L, null, "wrong-token")); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); } - @Nested - @DisplayName("addComment tests") - class AddCommentTests { - - @Test - @DisplayName("Admin can add a public comment") - void admin_canAddPublicComment() { - // Arrange - User admin = adminUser(); - Ticket ticket = openTicketWithReporter(reporterUser()); - CommentViewDTO expected = new CommentViewDTO(); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(expected); - - // Act - CommentViewDTO result = ticketCommentService.addComment(1L, dto("Hello", false), admin, null); - - // Assert - assertSame(expected, result); - verify(ticketCommentRepository).save(any()); - } - - @Test - @DisplayName("Admin can add an internal note") - void admin_canAddInternalNote() { - // Arrange - User admin = adminUser(); - Ticket ticket = openTicketWithReporter(reporterUser()); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); - - // Act + Assert - assertDoesNotThrow(() -> - ticketCommentService.addComment(1L, dto("Internal", true), admin, null)); - } - - @Test - @DisplayName("Assigned investigator can add a comment") - void assignedInvestigator_canAddComment() { - // Arrange - User investigator = investigatorUser(); - Ticket ticket = openTicketWithReporter(reporterUser()); - ticket.setInvestigator(investigator); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); - - // Act + Assert - assertDoesNotThrow(() -> - ticketCommentService.addComment(1L, dto("Note", false), investigator, null)); - } - - @Test - @DisplayName("Assigned investigator can add an internal note") - void assignedInvestigator_canAddInternalNote() { - // Arrange - User investigator = investigatorUser(); - Ticket ticket = openTicketWithReporter(reporterUser()); - ticket.setInvestigator(investigator); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); - - // Act + Assert - assertDoesNotThrow(() -> - ticketCommentService.addComment(1L, dto("Internal", true), investigator, null)); - } - - @Test - @DisplayName("Unassigned investigator should be forbidden") - void unassignedInvestigator_shouldThrowForbidden() { - // Arrange - User investigator = investigatorUser(); - Ticket ticket = openTicketWithReporter(reporterUser()); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketCommentService.addComment(1L, dto("Note", false), investigator, null)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - verify(ticketCommentRepository, never()).save(any()); - } - - @Test - @DisplayName("Reporter who owns the ticket can add a comment") - void owningReporter_canAddComment() { - // Arrange - User reporter = reporterUser(); - Ticket ticket = openTicketWithReporter(reporter); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); - - // Act + Assert - assertDoesNotThrow(() -> - ticketCommentService.addComment(1L, dto("My comment", false), reporter, null)); - } - - @Test - @DisplayName("Reporter cannot create an internal note") - void reporter_cannotCreateInternalNote() { - // Arrange - User reporter = reporterUser(); - Ticket ticket = openTicketWithReporter(reporter); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketCommentService.addComment(1L, dto("Secret", true), reporter, null)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - verify(ticketCommentRepository, never()).save(any()); - } - - @Test - @DisplayName("Reporter who does not own the ticket should be forbidden") - void nonOwningReporter_shouldThrowForbidden() { - // Arrange - User reporter = reporterUser(); - - User otherReporter = new User(); - otherReporter.setId(301L); - otherReporter.setRole(Role.REPORTER); - - Ticket ticket = openTicketWithReporter(otherReporter); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketCommentService.addComment(1L, dto("Note", false), reporter, null)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - verify(ticketCommentRepository, never()).save(any()); - } - - @Test - @DisplayName("Anonymous user with valid token can add a comment") - void anonymous_withValidToken_canAddComment() { - // Arrange - String token = "valid-token"; - Ticket ticket = anonymousTicket(token); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(ticketCommentMapper.entityToViewDTO(any())).thenReturn(new CommentViewDTO()); - - // Act + Assert - assertDoesNotThrow(() -> - ticketCommentService.addComment(1L, dto("Anonymous comment", false), null, token)); - } - - @Test - @DisplayName("Anonymous user cannot create an internal note") - void anonymous_cannotCreateInternalNote() { - // Arrange - String token = "valid-token"; - Ticket ticket = anonymousTicket(token); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketCommentService.addComment(1L, dto("Secret", true), null, token)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - verify(ticketCommentRepository, never()).save(any()); - } - - @Test - @DisplayName("Anonymous user with wrong token should be forbidden") - void anonymous_withWrongToken_shouldThrowForbidden() { - // Arrange - Ticket ticket = anonymousTicket("correct-token"); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketCommentService.addComment(1L, dto("Note", false), null, "wrong-token")); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - verify(ticketCommentRepository, never()).save(any()); - } - - @Test - @DisplayName("Anonymous user with no token should be forbidden") - void anonymous_withNoToken_shouldThrowForbidden() { - // Arrange - Ticket ticket = anonymousTicket("valid-token"); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketCommentService.addComment(1L, dto("Note", false), null, null)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - verify(ticketCommentRepository, never()).save(any()); - } - - @Test - @DisplayName("Should throw Not Found when ticket does not exist") - void addComment_ticketNotFound_shouldThrowNotFound() { - // Arrange - when(ticketRepository.findById(1L)).thenReturn(Optional.empty()); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketCommentService.addComment(1L, dto("Hello", false), adminUser(), null)); - - // Assert - assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); - verify(ticketCommentRepository, never()).save(any()); - } + @Test + @DisplayName("Should throw Not Found when ticket does not exist") + void getComments_ticketNotFound_shouldThrowNotFound() { + // Arrange + when(ticketRepository.findById(1L)).thenReturn(Optional.empty()); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketCommentService.getComments(1L, adminUser(), null)); + // Assert + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); } - @Nested - @DisplayName("getComments tests") - class GetCommentsTests { - - @Test - @DisplayName("Admin sees all comments including internal notes") - void admin_seesAllComments() { - // Arrange - User admin = adminUser(); - Ticket ticket = openTicketWithReporter(reporterUser()); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.findByTicketIdOrderByCreatedAtAsc(1L)).thenReturn(List.of()); - - // Act - ticketCommentService.getComments(1L, admin, null); - - // Assert - verify(ticketCommentRepository).findByTicketIdOrderByCreatedAtAsc(1L); - verify(ticketCommentRepository, never()).findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(any()); - } - - @Test - @DisplayName("Assigned investigator sees all comments including internal notes") - void assignedInvestigator_seesAllComments() { - // Arrange - User investigator = investigatorUser(); - Ticket ticket = openTicketWithReporter(reporterUser()); - ticket.setInvestigator(investigator); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.findByTicketIdOrderByCreatedAtAsc(1L)).thenReturn(List.of()); - - // Act - ticketCommentService.getComments(1L, investigator, null); - - // Assert - verify(ticketCommentRepository).findByTicketIdOrderByCreatedAtAsc(1L); - } - - @Test - @DisplayName("Reporter only sees public comments") - void reporter_seesOnlyPublicComments() { - // Arrange - User reporter = reporterUser(); - Ticket ticket = openTicketWithReporter(reporter); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(1L)).thenReturn(List.of()); - - // Act - ticketCommentService.getComments(1L, reporter, null); - - // Assert - verify(ticketCommentRepository).findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(1L); - verify(ticketCommentRepository, never()).findByTicketIdOrderByCreatedAtAsc(any()); - } - - @Test - @DisplayName("Anonymous reporter with valid token only sees public comments") - void anonymous_withValidToken_seesOnlyPublicComments() { - // Arrange - String token = "valid-token"; - Ticket ticket = anonymousTicket(token); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(1L)).thenReturn(List.of()); - - // Act - ticketCommentService.getComments(1L, null, token); - - // Assert - verify(ticketCommentRepository).findByTicketIdAndInternalNoteFalseOrderByCreatedAtAsc(1L); - verify(ticketCommentRepository, never()).findByTicketIdOrderByCreatedAtAsc(any()); - } - - @Test - @DisplayName("Anonymous reporter with wrong token should be forbidden") - void anonymous_withWrongToken_shouldThrowForbidden() { - // Arrange - Ticket ticket = anonymousTicket("correct-token"); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketCommentService.getComments(1L, null, "wrong-token")); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } - - @Test - @DisplayName("Should throw Not Found when ticket does not exist") - void getComments_ticketNotFound_shouldThrowNotFound() { - // Arrange - when(ticketRepository.findById(1L)).thenReturn(Optional.empty()); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketCommentService.getComments(1L, adminUser(), null)); - - // Assert - assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); - } - - @Test - @DisplayName("Returned comments are mapped correctly") - void getComments_returnsMappedDTOs() { - // Arrange - User admin = adminUser(); - Ticket ticket = openTicketWithReporter(reporterUser()); - TicketComment comment = new TicketComment(); - CommentViewDTO expected = new CommentViewDTO(); - - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketCommentRepository.findByTicketIdOrderByCreatedAtAsc(1L)).thenReturn(List.of(comment)); - when(ticketCommentMapper.entityToViewDTO(comment)).thenReturn(expected); - - // Act - List result = ticketCommentService.getComments(1L, admin, null); - - // Assert - assertEquals(1, result.size()); - assertSame(expected, result.getFirst()); - } + @Test + @DisplayName("Returned comments are mapped correctly") + void getComments_returnsMappedDTOs() { + // Arrange + User admin = adminUser(); + Ticket ticket = openTicketWithReporter(reporterUser()); + TicketComment comment = new TicketComment(); + CommentViewDTO expected = new CommentViewDTO(); + + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketCommentRepository.findByTicketIdOrderByCreatedAtAsc(1L)) + .thenReturn(List.of(comment)); + when(ticketCommentMapper.entityToViewDTO(comment)).thenReturn(expected); + + // Act + List result = ticketCommentService.getComments(1L, admin, null); + + // Assert + assertEquals(1, result.size()); + assertSame(expected, result.getFirst()); } -} \ No newline at end of file + } +} diff --git a/src/test/java/org/example/alfs/services/TicketServiceTest.java b/src/test/java/org/example/alfs/services/TicketServiceTest.java index 36b54d4..61c3b8a 100644 --- a/src/test/java/org/example/alfs/services/TicketServiceTest.java +++ b/src/test/java/org/example/alfs/services/TicketServiceTest.java @@ -1,5 +1,11 @@ package org.example.alfs.services; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.Optional; import org.example.alfs.dto.ticket.TicketCreateDTO; import org.example.alfs.dto.ticket.TicketViewDTO; import org.example.alfs.entities.Ticket; @@ -22,830 +28,845 @@ import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; -import java.util.List; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - @DisplayName("TicketService Test") @ExtendWith(MockitoExtension.class) class TicketServiceTest { - @Mock - TicketRepository ticketRepository; - @Mock - TicketMapper ticketMapper; - @Mock - UserRepository userRepository; - @Mock - SecurityUtils securityUtils; - @Mock - AuditService auditService; - - @InjectMocks - TicketService ticketService; - - private Ticket openTicket() { - Ticket t = new Ticket(); - t.setId(1L); - t.setStatus(TicketStatus.OPEN); - return t; - } - - private User adminUser() { - User u = new User(); - u.setId(100L); - u.setRole(Role.ADMIN); - return u; - } - - private User investigatorUser() { - User u = new User(); - u.setId(200L); - u.setRole(Role.INVESTIGATOR); - return u; - } - - private User reporterUser() { - User u = new User(); - u.setId(300L); - u.setRole(Role.REPORTER); - return u; - } - - @Nested - @DisplayName("createNewTicket tests") - class CreateNewTicketTests { - - @Test - @DisplayName("createNewTicket should set reporter as user") - void createNewTicket_shouldSetReporterAsUser() { - // Arrange - TicketCreateDTO dto = new TicketCreateDTO(); - dto.setTitle("Test Ticket"); - dto.setDescription("This is a test ticket"); - User reporter = reporterUser(); - - TicketViewDTO viewDTO = new TicketViewDTO(); - - when(securityUtils.getCurrentUserOrNull()).thenReturn(reporter); - when(ticketRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(ticketMapper.entityToViewDTO(any())).thenReturn(viewDTO); - - // Act - TicketViewDTO result = ticketService.createNewTicket(dto); - - // Assert - verify(ticketRepository).save(argThat(ticket -> - reporter.equals(ticket.getReporter()) && - ticket.getReporterToken() == null && - "Test Ticket".equals(ticket.getTitle()) && - "This is a test ticket".equals(ticket.getDescription()) - )); - assertNull(result.getToken()); - } - - @Test - @DisplayName("createNewTicket should set reporter token when user is not authenticated") - void createNewTicket_shouldSetReporterToken_whenAnonymous() { - // Arrange - TicketCreateDTO dto = new TicketCreateDTO(); - dto.setTitle("Anonymous Ticket"); - dto.setDescription("Filed anonymously"); - - TicketViewDTO viewDTO = new TicketViewDTO(); - - when(securityUtils.getCurrentUserOrNull()).thenReturn(null); - when(ticketRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(ticketMapper.entityToViewDTO(any())).thenReturn(viewDTO); - - // Act - ArgumentCaptor ticketCaptor = ArgumentCaptor.forClass(Ticket.class); - TicketViewDTO result = ticketService.createNewTicket(dto); - - // Assert - verify(ticketRepository).save(ticketCaptor.capture()); - Ticket saved = ticketCaptor.getValue(); - assertNull(saved.getReporter()); - assertNotNull(saved.getReporterToken()); - assertEquals(saved.getReporterToken(), result.getToken()); - } - } - - @Nested - @DisplayName("getTicketByToken tests") - class GetTicketByTokenTests { - - @Test - @DisplayName("Valid token should return ticket") - void getTicketByToken_shouldReturnTicket() { - // Arrange - Ticket ticket = new Ticket(); - String token = "valid-token"; - - when(ticketRepository.findByReporterToken(token)).thenReturn(Optional.of(ticket)); - when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); - - // Act + Assert - assertDoesNotThrow(() -> ticketService.getTicketByToken(token)); - verify(ticketMapper).entityToViewDTO(ticket); - } - - @Test - @DisplayName("Invalid token should throw Not found") - void getTicketByToken_invalidToken_shouldThrowNotFound() { - // Arrange - when(ticketRepository.findByReporterToken(any())).thenReturn(Optional.empty()); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getTicketByToken("invalid-token")); - - // Assert - assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); - } - } - - @Nested - @DisplayName("getTicketById tests") - class GetTicketByIdTests { - - @Test - @DisplayName("getTicketById should return ticket when found and access is granted") - void getTicketById_shouldReturnTicket_whenFound() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - TicketViewDTO expected = new TicketViewDTO(); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketMapper.entityToViewDTO(ticket)).thenReturn(expected); - - // Act - TicketViewDTO result = ticketService.getTicketById(1L); - - // Assert - assertSame(expected, result); - verify(ticketMapper).entityToViewDTO(ticket); - } - - @Test - @DisplayName("getTicketById should throw Not Found when ticket does not exist") - void getTicketById_shouldThrow_whenNotFound() { - // Arrange - when(ticketRepository.findById(1L)).thenReturn(Optional.empty()); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getTicketById(1L)); - - // Assert - assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); - } - } - - @Nested - @DisplayName("getMyTickets tests") - class GetMyTicketsTests { - - @Test - @DisplayName("Should return tickets for the current reporter") - void getMyTickets_shouldReturnTicketsForCurrentUser() { - // Arrange - User reporter = reporterUser(); - Ticket ticket = openTicket(); - TicketViewDTO expected = new TicketViewDTO(); - - when(securityUtils.getCurrentUser()).thenReturn(reporter); - when(ticketRepository.findByReporterId(reporter.getId())).thenReturn(List.of(ticket)); - when(ticketMapper.entityToViewDTO(ticket)).thenReturn(expected); - - // Act - List result = ticketService.getMyTickets(); - - // Assert - assertEquals(1, result.size()); - assertSame(expected, result.getFirst()); - verify(ticketRepository).findByReporterId(reporter.getId()); - } - - @Test - @DisplayName("Should return empty list when reporter has no tickets") - void getMyTickets_shouldReturnEmptyList_whenNoTickets() { - // Arrange - User reporter = reporterUser(); - - when(securityUtils.getCurrentUser()).thenReturn(reporter); - when(ticketRepository.findByReporterId(reporter.getId())).thenReturn(List.of()); - - // Act - List result = ticketService.getMyTickets(); - - // Assert - assertTrue(result.isEmpty()); - verify(ticketMapper, never()).entityToViewDTO(any()); - } - } - - @Nested - @DisplayName("getMyAssignedTickets tests") - class GetMyAssignedTickets { - - @Test - @DisplayName("Should return tickets for the current investigator") - void getMyAssignedTickets_shouldReturnTicketsForCurrentUser() { - // Arrange - User investigator = investigatorUser(); - Ticket ticket = openTicket(); - TicketViewDTO expected = new TicketViewDTO(); - - when(securityUtils.getCurrentUser()).thenReturn(investigator); - when(ticketRepository.findByInvestigatorId(investigator.getId())).thenReturn(List.of(ticket)); - when(ticketMapper.entityToViewDTO(ticket)).thenReturn(expected); - - // Act - List result = ticketService.getMyAssignedTickets(); - - // Assert - assertEquals(1, result.size()); - assertSame(expected, result.getFirst()); - verify(ticketRepository).findByInvestigatorId(investigator.getId()); - } - - @Test - @DisplayName("Should return empty list when investigator has no tickets") - void getMyAssignedTickets_shouldReturnEmptyList_whenNoTickets() { - // Arrange - User investigator = investigatorUser(); - - when(securityUtils.getCurrentUser()).thenReturn(investigator); - when(ticketRepository.findByInvestigatorId(investigator.getId())).thenReturn(List.of()); - - // Act - List result = ticketService.getMyAssignedTickets(); - - // Assert - assertTrue(result.isEmpty()); - verify(ticketMapper, never()).entityToViewDTO(any()); - } - } - - @Nested - @DisplayName("getTicketsByStatus tests") - class GetTicketsByStatusTests { - - @Test - @DisplayName("Admin can get tickets by status") - void admin_canGetTicketsByStatus() { - // Arrange - when(securityUtils.getCurrentUser()).thenReturn(adminUser()); - when(ticketRepository.findByStatus(TicketStatus.OPEN)).thenReturn(List.of()); - - // Act + Assert - assertDoesNotThrow(() -> ticketService.getTicketsByStatus(TicketStatus.OPEN)); - } - - @Test - @DisplayName("Non-admin cannot get tickets by status") - void nonAdmin_cannotGetTicketsByStatus() { - // Arrange - when(securityUtils.getCurrentUser()).thenReturn(investigatorUser()); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getTicketsByStatus(TicketStatus.OPEN)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } - } - - @Nested - @DisplayName("getTicketsByStatusAndInvestigator tests") - class GetTicketsByStatusAndInvestigatorTests { - - @Test - @DisplayName("Admin can filter any investigator's tickets") - void admin_canFilterAnyInvestigator() { - // Arrange - User admin = adminUser(); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findByStatusAndInvestigatorId(any(), any())).thenReturn(List.of()); - - // Act + Assert - assertDoesNotThrow(() -> ticketService.getTicketsByStatusAndInvestigator(TicketStatus.OPEN, 200L)); - } - - @Test - @DisplayName("Investigator can filter their own tickets") - void investigator_canFilterOwnTickets() { - // Arrange - User investigator = investigatorUser(); - - when(securityUtils.getCurrentUser()).thenReturn(investigator); - when(ticketRepository.findByStatusAndInvestigatorId(any(), any())).thenReturn(List.of()); - - // Act + Assert - assertDoesNotThrow(() -> ticketService.getTicketsByStatusAndInvestigator(TicketStatus.IN_PROGRESS, 200L)); - } - - @Test - @DisplayName("Investigator cannot filter other investigator's tickets") - void investigator_cannotFilterOthersTickets() { - // Arrange - User investigator = investigatorUser(); - - when(securityUtils.getCurrentUser()).thenReturn(investigator); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getTicketsByStatusAndInvestigator(TicketStatus.IN_PROGRESS, 201L)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } - - @Test - @DisplayName("Reporter should be forbidden from filtering by investigator ID") - void reporter_cannotFilterTickets_byInvestigatorId() { - // Arrange - User reporter = reporterUser(); - - when(securityUtils.getCurrentUser()).thenReturn(reporter); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getTicketsByStatusAndInvestigator(TicketStatus.IN_PROGRESS, 200L)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } - } - - @Nested - @DisplayName("checkAccess tests") - class CheckAccessTests { - - @Test - @DisplayName("Admin should always have access") - void admin_shouldHaveAccess() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); - - // Act + Assert - assertDoesNotThrow(() -> ticketService.getTicketById(1L)); - } - - @Test - @DisplayName("Assigned investigator should have access") - void investigator_shouldHaveAccessIfAssigned() { - // Arrange - Ticket ticket = openTicket(); - User investigator = investigatorUser(); - ticket.setInvestigator(investigator); - - when(securityUtils.getCurrentUser()).thenReturn(investigator); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); - - // Act + Assert - assertDoesNotThrow(() -> ticketService.getTicketById(1L)); - } - - @Test - @DisplayName("Unassigned investigator should not have access") - void unassignedInvestigator_shouldThrowForbidden() { - // Arrange - Ticket ticket = openTicket(); - User investigator = investigatorUser(); - - User otherInvestigator = new User(); - otherInvestigator.setId(201L); - otherInvestigator.setRole(Role.INVESTIGATOR); - ticket.setInvestigator(otherInvestigator); - - when(securityUtils.getCurrentUser()).thenReturn(investigator); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getTicketById(1L)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } - - @Test - @DisplayName("Reporter who owns the ticket should have access") - void owningReporter_shouldHaveAccess() { - // Arrange - Ticket ticket = openTicket(); - User reporter = reporterUser(); - ticket.setReporter(reporter); - - when(securityUtils.getCurrentUser()).thenReturn(reporter); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); - - // Act + Assert - assertDoesNotThrow(() -> ticketService.getTicketById(1L)); - } - - @Test - @DisplayName("Reporter who does not own the ticket should not have access") - void nonOwningReporter_shouldThrowForbidden() { - // Arrange - Ticket ticket = openTicket(); - User reporter = reporterUser(); - - User otherReporter = new User(); - otherReporter.setId(301L); - otherReporter.setRole(Role.REPORTER); - ticket.setReporter(otherReporter); - - when(securityUtils.getCurrentUser()).thenReturn(reporter); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getTicketById(1L)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } - - @Test - @DisplayName("Investigator with null investigator set on ticket should be forbidden") - void investigator_nullInvestigatorOnTicket_shouldThrowForbidden() { - Ticket ticket = openTicket(); - User investigator = investigatorUser(); - ticket.setInvestigator(null); - - when(securityUtils.getCurrentUser()).thenReturn(investigator); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getTicketById(1L)); - - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - } - } - - @Nested - @DisplayName("requireCurrentUser tests") - class RequireCurrentUserTests { - - @Test - @DisplayName("No authenticated user in security context should throw Unauthorized") - void noAuthenticatedUser_shouldThrowUnauthorized() { - // Arrange - when(securityUtils.getCurrentUser()) - .thenThrow(new RuntimeException("No authenticated user in security context")); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getMyTickets()); - - // Assert - assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); - verify(ticketRepository, never()).findByReporterId(any()); - } - - @Test - @DisplayName("Authenticated user not found in database should throw Unauthorized") - void userNotFoundInDatabase_shouldThrowUnauthorized() { - // Arrange - when(securityUtils.getCurrentUser()) - .thenThrow(new RuntimeException("Authenticated user not found in database")); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.getMyTickets()); - - // Assert - assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); - verify(ticketRepository, never()).findByReporterId(any()); - } - } - - @Nested - @DisplayName("updateTicketStatus tests") - class UpdateTicketStatusTests { - - @Test - @DisplayName("Valid transition should succeed") - void validTransition_shouldSucceed() { - // Arrange - Ticket ticket = openTicket(); - User investigator = investigatorUser(); - ticket.setInvestigator(investigator); - - when(securityUtils.getCurrentUser()).thenReturn(investigator); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketRepository.save(any())).thenReturn(ticket); - when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); - - // Act - ticketService.updateTicketStatus(1L, TicketStatus.IN_PROGRESS); - - // Assert - assertEquals(TicketStatus.IN_PROGRESS, ticket.getStatus()); - verify(ticketRepository).save(ticket); - } - - @Test - @DisplayName("Invalid transition should throw Bad Request") - void invalidTransition_shouldThrowBadRequest() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.updateTicketStatus(1L, TicketStatus.CLOSED)); - - // Assert - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - - @Test - @DisplayName("Reporter should not be able to update status") - void reporterStatusUpdate_shouldThrowForbidden() { - // Arrange - Ticket ticket = openTicket(); - User reporter = reporterUser(); - - when(securityUtils.getCurrentUser()).thenReturn(reporter); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.updateTicketStatus(1L, TicketStatus.CLOSED)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - - @Test - @DisplayName("Same status should return without changes") - void sameStatus_shouldReturn() { - //Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); - - // Act - TicketViewDTO result = ticketService.updateTicketStatus(1L, TicketStatus.OPEN); - - // Assert - assertNotNull(result); - verify(ticketRepository, never()).save(any()); - } - - @Test - @DisplayName("Status change without investigator should throw Bad Request") - void statusChange_withoutInvestigator_shouldThrowBadRequest() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - ticket.setInvestigator(null); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.updateTicketStatus(1L, TicketStatus.IN_PROGRESS)); - - // Assert - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - - @Test - @DisplayName("Investigator not assigned should be forbidden") - void investigator_notAssigned_shouldBeForbidden() { - // Arrange - Ticket ticket = openTicket(); - User investigator = investigatorUser(); - - User otherInvestigator = new User(); - otherInvestigator.setId(201L); - otherInvestigator.setRole(Role.INVESTIGATOR); - ticket.setInvestigator(otherInvestigator); - - when(securityUtils.getCurrentUser()).thenReturn(investigator); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.updateTicketStatus(1L, TicketStatus.IN_PROGRESS)); - - // Assert - assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - } - - @Nested - @DisplayName("assignInvestigator tests") - class AssignInvestigatorTests { - - @Test - @DisplayName("Assigning investigator should succeed") - void assignInvestigator_shouldSucceed() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - User investigator = investigatorUser(); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(userRepository.findById(investigator.getId())).thenReturn(Optional.of(investigator)); - when(ticketRepository.save(any())).thenReturn(ticket); - when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); - - // Act - ticketService.assignInvestigator(1L, investigator.getId()); - - // Assert - assertEquals(investigator, ticket.getInvestigator()); - assertEquals(TicketStatus.IN_PROGRESS, ticket.getStatus()); - verify(ticketRepository).save(ticket); - } - - @Test - @DisplayName("Assigning investigator should throw Bad Request when investigatorId is null") - void assignInvestigator_shouldThrowBadRequest_whenInvestigatorIsNull() { - // Arrange + Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.assignInvestigator(1L, null)); - - // Assert - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - - @Test - @DisplayName("Assigning investigator should throw Conflict when ticket is already assigned") - void assignInvestigator_shouldThrowConflict_whenTicketAlreadyAssigned() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - User investigator = investigatorUser(); - ticket.setInvestigator(investigator); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.assignInvestigator(1L, investigator.getId())); - - // Assert - assertEquals(HttpStatus.CONFLICT, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - - @Test - @DisplayName("Assigning investigator should throw Bad Request when ticket is not open") - void assignInvestigator_shouldThrowBadRequest_whenIsNotOpen() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - User investigator = investigatorUser(); - ticket.setStatus(TicketStatus.CLOSED); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.assignInvestigator(1L, investigator.getId())); - - // Assert - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - - @Test - @DisplayName("Assigning investigator should throw Bad Request when user is not investigator") - void assignInvestigator_shouldThrowBadRequest_whenUserIsNotInvestigator() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - User reporter = reporterUser(); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(userRepository.findById(reporter.getId())).thenReturn(Optional.of(reporter)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.assignInvestigator(1L, reporter.getId())); - - // Assert - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - - } - - @Nested - @DisplayName("unassignInvestigator tests") - class UnassignInvestigatorTests { - - @Test - @DisplayName("Unassigning investigator should succeed") - void unassignInvestigator_shouldSucceed() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - User investigator = investigatorUser(); - investigator.setUsername("inv-user"); - ticket.setInvestigator(investigator); - ticket.setStatus(TicketStatus.IN_PROGRESS); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - when(ticketRepository.save(any())).thenReturn(ticket); - when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); - - // Act - ticketService.unassignInvestigator(1L); - - // Assert - assertNull(ticket.getInvestigator()); - assertEquals(TicketStatus.OPEN, ticket.getStatus()); - verify(ticketRepository).save(ticket); - - verify(auditService).log( - eq(AuditAction.UNASSIGNED), - eq("investigator"), - eq("inv-user"), - isNull(), - any(), - eq(admin) - ); - } - - @Test - @DisplayName("Unassigning investigator should throw Bad Request when no investigator is assigned") - void unassignInvestigator_shouldThrowBadRequest_whenNoInvestigatorAssigned() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - ticket.setInvestigator(null); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.unassignInvestigator(1L)); - - // Assert - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - - @Test - @DisplayName("Unassigning investigator should throw Bad Request when ticket is not in progress") - void unassignInvestigator_shouldThrowBadRequest_whenTicketNotInProgress() { - // Arrange - Ticket ticket = openTicket(); - User admin = adminUser(); - User investigator = investigatorUser(); - ticket.setInvestigator(investigator); - ticket.setStatus(TicketStatus.OPEN); - - when(securityUtils.getCurrentUser()).thenReturn(admin); - when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); - - // Act - ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> - ticketService.unassignInvestigator(1L)); - - // Assert - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - verify(ticketRepository, never()).save(any()); - } - } -} \ No newline at end of file + @Mock TicketRepository ticketRepository; + @Mock TicketMapper ticketMapper; + @Mock UserRepository userRepository; + @Mock SecurityUtils securityUtils; + @Mock AuditService auditService; + + @InjectMocks TicketService ticketService; + + private Ticket openTicket() { + Ticket t = new Ticket(); + t.setId(1L); + t.setStatus(TicketStatus.OPEN); + return t; + } + + private User adminUser() { + User u = new User(); + u.setId(100L); + u.setRole(Role.ADMIN); + return u; + } + + private User investigatorUser() { + User u = new User(); + u.setId(200L); + u.setRole(Role.INVESTIGATOR); + return u; + } + + private User reporterUser() { + User u = new User(); + u.setId(300L); + u.setRole(Role.REPORTER); + return u; + } + + @Nested + @DisplayName("createNewTicket tests") + class CreateNewTicketTests { + + @Test + @DisplayName("createNewTicket should set reporter as user") + void createNewTicket_shouldSetReporterAsUser() { + // Arrange + TicketCreateDTO dto = new TicketCreateDTO(); + dto.setTitle("Test Ticket"); + dto.setDescription("This is a test ticket"); + User reporter = reporterUser(); + + TicketViewDTO viewDTO = new TicketViewDTO(); + + when(securityUtils.getCurrentUserOrNull()).thenReturn(reporter); + when(ticketRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(ticketMapper.entityToViewDTO(any())).thenReturn(viewDTO); + + // Act + TicketViewDTO result = ticketService.createNewTicket(dto); + + // Assert + verify(ticketRepository) + .save( + argThat( + ticket -> + reporter.equals(ticket.getReporter()) + && ticket.getReporterToken() == null + && "Test Ticket".equals(ticket.getTitle()) + && "This is a test ticket".equals(ticket.getDescription()))); + assertNull(result.getToken()); + } + + @Test + @DisplayName("createNewTicket should set reporter token when user is not authenticated") + void createNewTicket_shouldSetReporterToken_whenAnonymous() { + // Arrange + TicketCreateDTO dto = new TicketCreateDTO(); + dto.setTitle("Anonymous Ticket"); + dto.setDescription("Filed anonymously"); + + TicketViewDTO viewDTO = new TicketViewDTO(); + + when(securityUtils.getCurrentUserOrNull()).thenReturn(null); + when(ticketRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(ticketMapper.entityToViewDTO(any())).thenReturn(viewDTO); + + // Act + ArgumentCaptor ticketCaptor = ArgumentCaptor.forClass(Ticket.class); + TicketViewDTO result = ticketService.createNewTicket(dto); + + // Assert + verify(ticketRepository).save(ticketCaptor.capture()); + Ticket saved = ticketCaptor.getValue(); + assertNull(saved.getReporter()); + assertNotNull(saved.getReporterToken()); + assertEquals(saved.getReporterToken(), result.getToken()); + } + } + + @Nested + @DisplayName("getTicketByToken tests") + class GetTicketByTokenTests { + + @Test + @DisplayName("Valid token should return ticket") + void getTicketByToken_shouldReturnTicket() { + // Arrange + Ticket ticket = new Ticket(); + String token = "valid-token"; + + when(ticketRepository.findByReporterToken(token)).thenReturn(Optional.of(ticket)); + when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); + + // Act + Assert + assertDoesNotThrow(() -> ticketService.getTicketByToken(token)); + verify(ticketMapper).entityToViewDTO(ticket); + } + + @Test + @DisplayName("Invalid token should throw Not found") + void getTicketByToken_invalidToken_shouldThrowNotFound() { + // Arrange + when(ticketRepository.findByReporterToken(any())).thenReturn(Optional.empty()); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, () -> ticketService.getTicketByToken("invalid-token")); + + // Assert + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + } + + @Nested + @DisplayName("getTicketById tests") + class GetTicketByIdTests { + + @Test + @DisplayName("getTicketById should return ticket when found and access is granted") + void getTicketById_shouldReturnTicket_whenFound() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + TicketViewDTO expected = new TicketViewDTO(); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketMapper.entityToViewDTO(ticket)).thenReturn(expected); + + // Act + TicketViewDTO result = ticketService.getTicketById(1L); + + // Assert + assertSame(expected, result); + verify(ticketMapper).entityToViewDTO(ticket); + } + + @Test + @DisplayName("getTicketById should throw Not Found when ticket does not exist") + void getTicketById_shouldThrow_whenNotFound() { + // Arrange + when(ticketRepository.findById(1L)).thenReturn(Optional.empty()); + + // Act + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> ticketService.getTicketById(1L)); + + // Assert + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + } + + @Nested + @DisplayName("getMyTickets tests") + class GetMyTicketsTests { + + @Test + @DisplayName("Should return tickets for the current reporter") + void getMyTickets_shouldReturnTicketsForCurrentUser() { + // Arrange + User reporter = reporterUser(); + Ticket ticket = openTicket(); + TicketViewDTO expected = new TicketViewDTO(); + + when(securityUtils.getCurrentUser()).thenReturn(reporter); + when(ticketRepository.findByReporterId(reporter.getId())).thenReturn(List.of(ticket)); + when(ticketMapper.entityToViewDTO(ticket)).thenReturn(expected); + + // Act + List result = ticketService.getMyTickets(); + + // Assert + assertEquals(1, result.size()); + assertSame(expected, result.getFirst()); + verify(ticketRepository).findByReporterId(reporter.getId()); + } + + @Test + @DisplayName("Should return empty list when reporter has no tickets") + void getMyTickets_shouldReturnEmptyList_whenNoTickets() { + // Arrange + User reporter = reporterUser(); + + when(securityUtils.getCurrentUser()).thenReturn(reporter); + when(ticketRepository.findByReporterId(reporter.getId())).thenReturn(List.of()); + + // Act + List result = ticketService.getMyTickets(); + + // Assert + assertTrue(result.isEmpty()); + verify(ticketMapper, never()).entityToViewDTO(any()); + } + } + + @Nested + @DisplayName("getMyAssignedTickets tests") + class GetMyAssignedTickets { + + @Test + @DisplayName("Should return tickets for the current investigator") + void getMyAssignedTickets_shouldReturnTicketsForCurrentUser() { + // Arrange + User investigator = investigatorUser(); + Ticket ticket = openTicket(); + TicketViewDTO expected = new TicketViewDTO(); + + when(securityUtils.getCurrentUser()).thenReturn(investigator); + when(ticketRepository.findByInvestigatorId(investigator.getId())).thenReturn(List.of(ticket)); + when(ticketMapper.entityToViewDTO(ticket)).thenReturn(expected); + + // Act + List result = ticketService.getMyAssignedTickets(); + + // Assert + assertEquals(1, result.size()); + assertSame(expected, result.getFirst()); + verify(ticketRepository).findByInvestigatorId(investigator.getId()); + } + + @Test + @DisplayName("Should return empty list when investigator has no tickets") + void getMyAssignedTickets_shouldReturnEmptyList_whenNoTickets() { + // Arrange + User investigator = investigatorUser(); + + when(securityUtils.getCurrentUser()).thenReturn(investigator); + when(ticketRepository.findByInvestigatorId(investigator.getId())).thenReturn(List.of()); + + // Act + List result = ticketService.getMyAssignedTickets(); + + // Assert + assertTrue(result.isEmpty()); + verify(ticketMapper, never()).entityToViewDTO(any()); + } + } + + @Nested + @DisplayName("getTicketsByStatus tests") + class GetTicketsByStatusTests { + + @Test + @DisplayName("Admin can get tickets by status") + void admin_canGetTicketsByStatus() { + // Arrange + when(securityUtils.getCurrentUser()).thenReturn(adminUser()); + when(ticketRepository.findByStatus(TicketStatus.OPEN)).thenReturn(List.of()); + + // Act + Assert + assertDoesNotThrow(() -> ticketService.getTicketsByStatus(TicketStatus.OPEN)); + } + + @Test + @DisplayName("Non-admin cannot get tickets by status") + void nonAdmin_cannotGetTicketsByStatus() { + // Arrange + when(securityUtils.getCurrentUser()).thenReturn(investigatorUser()); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketService.getTicketsByStatus(TicketStatus.OPEN)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + } + + @Nested + @DisplayName("getTicketsByStatusAndInvestigator tests") + class GetTicketsByStatusAndInvestigatorTests { + + @Test + @DisplayName("Admin can filter any investigator's tickets") + void admin_canFilterAnyInvestigator() { + // Arrange + User admin = adminUser(); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findByStatusAndInvestigatorId(any(), any())).thenReturn(List.of()); + + // Act + Assert + assertDoesNotThrow( + () -> ticketService.getTicketsByStatusAndInvestigator(TicketStatus.OPEN, 200L)); + } + + @Test + @DisplayName("Investigator can filter their own tickets") + void investigator_canFilterOwnTickets() { + // Arrange + User investigator = investigatorUser(); + + when(securityUtils.getCurrentUser()).thenReturn(investigator); + when(ticketRepository.findByStatusAndInvestigatorId(any(), any())).thenReturn(List.of()); + + // Act + Assert + assertDoesNotThrow( + () -> ticketService.getTicketsByStatusAndInvestigator(TicketStatus.IN_PROGRESS, 200L)); + } + + @Test + @DisplayName("Investigator cannot filter other investigator's tickets") + void investigator_cannotFilterOthersTickets() { + // Arrange + User investigator = investigatorUser(); + + when(securityUtils.getCurrentUser()).thenReturn(investigator); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + ticketService.getTicketsByStatusAndInvestigator(TicketStatus.IN_PROGRESS, 201L)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + @DisplayName("Reporter should be forbidden from filtering by investigator ID") + void reporter_cannotFilterTickets_byInvestigatorId() { + // Arrange + User reporter = reporterUser(); + + when(securityUtils.getCurrentUser()).thenReturn(reporter); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> + ticketService.getTicketsByStatusAndInvestigator(TicketStatus.IN_PROGRESS, 200L)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + } + + @Nested + @DisplayName("checkAccess tests") + class CheckAccessTests { + + @Test + @DisplayName("Admin should always have access") + void admin_shouldHaveAccess() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); + + // Act + Assert + assertDoesNotThrow(() -> ticketService.getTicketById(1L)); + } + + @Test + @DisplayName("Assigned investigator should have access") + void investigator_shouldHaveAccessIfAssigned() { + // Arrange + Ticket ticket = openTicket(); + User investigator = investigatorUser(); + ticket.setInvestigator(investigator); + + when(securityUtils.getCurrentUser()).thenReturn(investigator); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); + + // Act + Assert + assertDoesNotThrow(() -> ticketService.getTicketById(1L)); + } + + @Test + @DisplayName("Unassigned investigator should not have access") + void unassignedInvestigator_shouldThrowForbidden() { + // Arrange + Ticket ticket = openTicket(); + User investigator = investigatorUser(); + + User otherInvestigator = new User(); + otherInvestigator.setId(201L); + otherInvestigator.setRole(Role.INVESTIGATOR); + ticket.setInvestigator(otherInvestigator); + + when(securityUtils.getCurrentUser()).thenReturn(investigator); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> ticketService.getTicketById(1L)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + @DisplayName("Reporter who owns the ticket should have access") + void owningReporter_shouldHaveAccess() { + // Arrange + Ticket ticket = openTicket(); + User reporter = reporterUser(); + ticket.setReporter(reporter); + + when(securityUtils.getCurrentUser()).thenReturn(reporter); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); + + // Act + Assert + assertDoesNotThrow(() -> ticketService.getTicketById(1L)); + } + + @Test + @DisplayName("Reporter who does not own the ticket should not have access") + void nonOwningReporter_shouldThrowForbidden() { + // Arrange + Ticket ticket = openTicket(); + User reporter = reporterUser(); + + User otherReporter = new User(); + otherReporter.setId(301L); + otherReporter.setRole(Role.REPORTER); + ticket.setReporter(otherReporter); + + when(securityUtils.getCurrentUser()).thenReturn(reporter); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> ticketService.getTicketById(1L)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + + @Test + @DisplayName("Investigator with null investigator set on ticket should be forbidden") + void investigator_nullInvestigatorOnTicket_shouldThrowForbidden() { + Ticket ticket = openTicket(); + User investigator = investigatorUser(); + ticket.setInvestigator(null); + + when(securityUtils.getCurrentUser()).thenReturn(investigator); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> ticketService.getTicketById(1L)); + + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + } + } + + @Nested + @DisplayName("requireCurrentUser tests") + class RequireCurrentUserTests { + + @Test + @DisplayName("No authenticated user in security context should throw Unauthorized") + void noAuthenticatedUser_shouldThrowUnauthorized() { + // Arrange + when(securityUtils.getCurrentUser()) + .thenThrow(new RuntimeException("No authenticated user in security context")); + + // Act + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> ticketService.getMyTickets()); + + // Assert + assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); + verify(ticketRepository, never()).findByReporterId(any()); + } + + @Test + @DisplayName("Authenticated user not found in database should throw Unauthorized") + void userNotFoundInDatabase_shouldThrowUnauthorized() { + // Arrange + when(securityUtils.getCurrentUser()) + .thenThrow(new RuntimeException("Authenticated user not found in database")); + + // Act + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> ticketService.getMyTickets()); + + // Assert + assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); + verify(ticketRepository, never()).findByReporterId(any()); + } + } + + @Nested + @DisplayName("updateTicketStatus tests") + class UpdateTicketStatusTests { + + @Test + @DisplayName("Valid transition should succeed") + void validTransition_shouldSucceed() { + // Arrange + Ticket ticket = openTicket(); + User investigator = investigatorUser(); + ticket.setInvestigator(investigator); + + when(securityUtils.getCurrentUser()).thenReturn(investigator); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketRepository.save(any())).thenReturn(ticket); + when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); + + // Act + ticketService.updateTicketStatus(1L, TicketStatus.IN_PROGRESS); + + // Assert + assertEquals(TicketStatus.IN_PROGRESS, ticket.getStatus()); + verify(ticketRepository).save(ticket); + } + + @Test + @DisplayName("Invalid transition should throw Bad Request") + void invalidTransition_shouldThrowBadRequest() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketService.updateTicketStatus(1L, TicketStatus.CLOSED)); + + // Assert + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + + @Test + @DisplayName("Reporter should not be able to update status") + void reporterStatusUpdate_shouldThrowForbidden() { + // Arrange + Ticket ticket = openTicket(); + User reporter = reporterUser(); + + when(securityUtils.getCurrentUser()).thenReturn(reporter); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketService.updateTicketStatus(1L, TicketStatus.CLOSED)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + + @Test + @DisplayName("Same status should return without changes") + void sameStatus_shouldReturn() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); + + // Act + TicketViewDTO result = ticketService.updateTicketStatus(1L, TicketStatus.OPEN); + + // Assert + assertNotNull(result); + verify(ticketRepository, never()).save(any()); + } + + @Test + @DisplayName("Status change without investigator should throw Bad Request") + void statusChange_withoutInvestigator_shouldThrowBadRequest() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + ticket.setInvestigator(null); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketService.updateTicketStatus(1L, TicketStatus.IN_PROGRESS)); + + // Assert + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + + @Test + @DisplayName("Investigator not assigned should be forbidden") + void investigator_notAssigned_shouldBeForbidden() { + // Arrange + Ticket ticket = openTicket(); + User investigator = investigatorUser(); + + User otherInvestigator = new User(); + otherInvestigator.setId(201L); + otherInvestigator.setRole(Role.INVESTIGATOR); + ticket.setInvestigator(otherInvestigator); + + when(securityUtils.getCurrentUser()).thenReturn(investigator); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketService.updateTicketStatus(1L, TicketStatus.IN_PROGRESS)); + + // Assert + assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("assignInvestigator tests") + class AssignInvestigatorTests { + + @Test + @DisplayName("Assigning investigator should succeed") + void assignInvestigator_shouldSucceed() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + User investigator = investigatorUser(); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(userRepository.findById(investigator.getId())).thenReturn(Optional.of(investigator)); + when(ticketRepository.save(any())).thenReturn(ticket); + when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); + + // Act + ticketService.assignInvestigator(1L, investigator.getId()); + + // Assert + assertEquals(investigator, ticket.getInvestigator()); + assertEquals(TicketStatus.IN_PROGRESS, ticket.getStatus()); + verify(ticketRepository).save(ticket); + } + + @Test + @DisplayName("Assigning investigator should throw Bad Request when investigatorId is null") + void assignInvestigator_shouldThrowBadRequest_whenInvestigatorIsNull() { + // Arrange + Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, () -> ticketService.assignInvestigator(1L, null)); + + // Assert + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + + @Test + @DisplayName("Assigning investigator should throw Conflict when ticket is already assigned") + void assignInvestigator_shouldThrowConflict_whenTicketAlreadyAssigned() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + User investigator = investigatorUser(); + ticket.setInvestigator(investigator); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketService.assignInvestigator(1L, investigator.getId())); + + // Assert + assertEquals(HttpStatus.CONFLICT, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + + @Test + @DisplayName("Assigning investigator should throw Bad Request when ticket is not open") + void assignInvestigator_shouldThrowBadRequest_whenIsNotOpen() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + User investigator = investigatorUser(); + ticket.setStatus(TicketStatus.CLOSED); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketService.assignInvestigator(1L, investigator.getId())); + + // Assert + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + + @Test + @DisplayName("Assigning investigator should throw Bad Request when user is not investigator") + void assignInvestigator_shouldThrowBadRequest_whenUserIsNotInvestigator() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + User reporter = reporterUser(); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(userRepository.findById(reporter.getId())).thenReturn(Optional.of(reporter)); + + // Act + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> ticketService.assignInvestigator(1L, reporter.getId())); + + // Assert + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("unassignInvestigator tests") + class UnassignInvestigatorTests { + + @Test + @DisplayName("Unassigning investigator should succeed") + void unassignInvestigator_shouldSucceed() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + User investigator = investigatorUser(); + investigator.setUsername("inv-user"); + ticket.setInvestigator(investigator); + ticket.setStatus(TicketStatus.IN_PROGRESS); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + when(ticketRepository.save(any())).thenReturn(ticket); + when(ticketMapper.entityToViewDTO(any())).thenReturn(new TicketViewDTO()); + + // Act + ticketService.unassignInvestigator(1L); + + // Assert + assertNull(ticket.getInvestigator()); + assertEquals(TicketStatus.OPEN, ticket.getStatus()); + verify(ticketRepository).save(ticket); + + verify(auditService) + .log( + eq(AuditAction.UNASSIGNED), + eq("investigator"), + eq("inv-user"), + isNull(), + any(), + eq(admin)); + } + + @Test + @DisplayName( + "Unassigning investigator should throw Bad Request when no investigator is assigned") + void unassignInvestigator_shouldThrowBadRequest_whenNoInvestigatorAssigned() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + ticket.setInvestigator(null); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> ticketService.unassignInvestigator(1L)); + + // Assert + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + + @Test + @DisplayName("Unassigning investigator should throw Bad Request when ticket is not in progress") + void unassignInvestigator_shouldThrowBadRequest_whenTicketNotInProgress() { + // Arrange + Ticket ticket = openTicket(); + User admin = adminUser(); + User investigator = investigatorUser(); + ticket.setInvestigator(investigator); + ticket.setStatus(TicketStatus.OPEN); + + when(securityUtils.getCurrentUser()).thenReturn(admin); + when(ticketRepository.findById(1L)).thenReturn(Optional.of(ticket)); + + // Act + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> ticketService.unassignInvestigator(1L)); + + // Assert + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(ticketRepository, never()).save(any()); + } + } +} diff --git a/src/test/java/org/example/alfs/services/UserServiceTest.java b/src/test/java/org/example/alfs/services/UserServiceTest.java index 10947f9..ff0c6e6 100644 --- a/src/test/java/org/example/alfs/services/UserServiceTest.java +++ b/src/test/java/org/example/alfs/services/UserServiceTest.java @@ -1,5 +1,10 @@ package org.example.alfs.services; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; import org.example.alfs.entities.User; import org.example.alfs.enums.Role; import org.example.alfs.repositories.UserRepository; @@ -10,49 +15,40 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.List; +@ExtendWith(MockitoExtension.class) +class UserServiceTest { -import static org.assertj.core.api.Assertions.assertThat; + @Mock private UserRepository userRepository; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; + @InjectMocks private UserService userService; -@ExtendWith(MockitoExtension.class) -class UserServiceTest { + @Test + @DisplayName("getAllInvestigators should return all investigators from the repository") + void getAllInvestigators_returnsInvestigatorsFromRepository() { + // Arrange + User investigator = new User(); + investigator.setRole(Role.INVESTIGATOR); + when(userRepository.findByRole(Role.INVESTIGATOR)).thenReturn(List.of(investigator)); + + // Act + List result = userService.getAllInvestigators(); + + // Assert + assertThat(result).containsExactly(investigator); + verify(userRepository).findByRole(Role.INVESTIGATOR); + } + + @Test + @DisplayName("getAllInvestigators should return an empty list when no investigators exist") + void getAllInvestigators_whenNoneExist_returnsEmptyList() { + // Arrange + when(userRepository.findByRole(Role.INVESTIGATOR)).thenReturn(List.of()); + + // Act + List result = userService.getAllInvestigators(); - @Mock - private UserRepository userRepository; - - @InjectMocks - private UserService userService; - - @Test - @DisplayName("getAllInvestigators should return all investigators from the repository") - void getAllInvestigators_returnsInvestigatorsFromRepository() { - // Arrange - User investigator = new User(); - investigator.setRole(Role.INVESTIGATOR); - when(userRepository.findByRole(Role.INVESTIGATOR)).thenReturn(List.of(investigator)); - - // Act - List result = userService.getAllInvestigators(); - - // Assert - assertThat(result).containsExactly(investigator); - verify(userRepository).findByRole(Role.INVESTIGATOR); - } - - @Test - @DisplayName("getAllInvestigators should return an empty list when no investigators exist") - void getAllInvestigators_whenNoneExist_returnsEmptyList() { - // Arrange - when(userRepository.findByRole(Role.INVESTIGATOR)).thenReturn(List.of()); - - // Act - List result = userService.getAllInvestigators(); - - // Assert - assertThat(result).isEmpty(); - verify(userRepository).findByRole(Role.INVESTIGATOR); - } -} \ No newline at end of file + // Assert + assertThat(result).isEmpty(); + verify(userRepository).findByRole(Role.INVESTIGATOR); + } +} From b2967872a20fecdb70597d9e33298d8457614fc4 Mon Sep 17 00:00:00 2001 From: Fiona Friberg Date: Mon, 27 Apr 2026 14:32:24 +0200 Subject: [PATCH 2/9] exclude test classes from ArchUnit checks --- src/test/java/org/example/alfs/ArchitectureTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/example/alfs/ArchitectureTest.java b/src/test/java/org/example/alfs/ArchitectureTest.java index 2435d1c..21a1808 100644 --- a/src/test/java/org/example/alfs/ArchitectureTest.java +++ b/src/test/java/org/example/alfs/ArchitectureTest.java @@ -4,6 +4,7 @@ import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; import org.junit.jupiter.api.Test; /** @@ -14,7 +15,10 @@ */ class ArchitectureTest { - private final JavaClasses classes = new ClassFileImporter().importPackages("org.example.alfs"); + private final JavaClasses classes = new ClassFileImporter() + .withImportOption(new ImportOption.DoNotIncludeTests()) + .importPackages("org.example.alfs"); + /** * Layer Rules From 994c570de3bb9fb110c1f0c65f299ad28bbf422e Mon Sep 17 00:00:00 2001 From: Fiona Friberg Date: Mon, 27 Apr 2026 14:40:37 +0200 Subject: [PATCH 3/9] apply spotless formatting --- .../java/org/example/alfs/controllers/TicketController.java | 4 ---- src/test/java/org/example/alfs/ArchitectureTest.java | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/example/alfs/controllers/TicketController.java b/src/main/java/org/example/alfs/controllers/TicketController.java index 8bef5df..391dbc4 100644 --- a/src/main/java/org/example/alfs/controllers/TicketController.java +++ b/src/main/java/org/example/alfs/controllers/TicketController.java @@ -16,7 +16,6 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.support.RedirectAttributes; - @Controller @RequestMapping("/tickets") public class TicketController { @@ -178,7 +177,4 @@ public String ticketCreated(@RequestParam String token, Model model) { model.addAttribute("token", token); return "ticket-created"; } - // create comment - // View comment - // upload attachment } diff --git a/src/test/java/org/example/alfs/ArchitectureTest.java b/src/test/java/org/example/alfs/ArchitectureTest.java index 21a1808..1d94420 100644 --- a/src/test/java/org/example/alfs/ArchitectureTest.java +++ b/src/test/java/org/example/alfs/ArchitectureTest.java @@ -15,11 +15,11 @@ */ class ArchitectureTest { - private final JavaClasses classes = new ClassFileImporter() + private final JavaClasses classes = + new ClassFileImporter() .withImportOption(new ImportOption.DoNotIncludeTests()) .importPackages("org.example.alfs"); - /** * Layer Rules * From af20a4b77aea4a28a36faf822b3dc10c6811f6f0 Mon Sep 17 00:00:00 2001 From: Fiona Friberg Date: Mon, 27 Apr 2026 14:43:47 +0200 Subject: [PATCH 4/9] remove demo-data --- src/main/resources/application.properties | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7c5444f..76e45ae 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,5 @@ gg.jte.development-mode=true spring.application.name=alfs -server.address=0.0.0.0 -spring.profiles.active=demo #Disable whitelabel spring.web.error.whitelabel.enabled=false From 8e57deac20f172f9973622011ca8b3113b1cf3b4 Mon Sep 17 00:00:00 2001 From: Simon Forsberg Date: Mon, 27 Apr 2026 15:26:44 +0200 Subject: [PATCH 5/9] Move `TicketControllerIT` to `integration` package --- .../alfs/{controllers => integration}/TicketControllerIT.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/test/java/org/example/alfs/{controllers => integration}/TicketControllerIT.java (99%) diff --git a/src/test/java/org/example/alfs/controllers/TicketControllerIT.java b/src/test/java/org/example/alfs/integration/TicketControllerIT.java similarity index 99% rename from src/test/java/org/example/alfs/controllers/TicketControllerIT.java rename to src/test/java/org/example/alfs/integration/TicketControllerIT.java index c9799d4..27e6930 100644 --- a/src/test/java/org/example/alfs/controllers/TicketControllerIT.java +++ b/src/test/java/org/example/alfs/integration/TicketControllerIT.java @@ -1,4 +1,4 @@ -package org.example.alfs.controllers; +package org.example.alfs.integration; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; From 233b1c3e061e212c81d18db7bd0713839502aaa4 Mon Sep 17 00:00:00 2001 From: Fiona Friberg Date: Mon, 27 Apr 2026 15:26:44 +0200 Subject: [PATCH 6/9] correct OpenAPI schema --- .../org/example/alfs/controllers/TicketCommentController.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/example/alfs/controllers/TicketCommentController.java b/src/main/java/org/example/alfs/controllers/TicketCommentController.java index aa1ec2e..1e770f0 100644 --- a/src/main/java/org/example/alfs/controllers/TicketCommentController.java +++ b/src/main/java/org/example/alfs/controllers/TicketCommentController.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -56,7 +57,7 @@ public String addComment( content = @Content( mediaType = "application/json", - schema = @Schema(implementation = CommentViewDTO.class))), + array = @ArraySchema(schema = @Schema(implementation = CommentViewDTO.class)))), @ApiResponse(responseCode = "403", description = "Access denied"), @ApiResponse(responseCode = "404", description = "Ticket not found") }) From f0ffd3a8525cfe95e9fbb3ee10bd2cacb4f157df Mon Sep 17 00:00:00 2001 From: Fiona Friberg Date: Mon, 27 Apr 2026 15:45:55 +0200 Subject: [PATCH 7/9] apply spotless formatting to TicketControllerIT --- .../java/org/example/alfs/integration/TicketControllerIT.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/org/example/alfs/integration/TicketControllerIT.java b/src/test/java/org/example/alfs/integration/TicketControllerIT.java index eafa868..27e6930 100644 --- a/src/test/java/org/example/alfs/integration/TicketControllerIT.java +++ b/src/test/java/org/example/alfs/integration/TicketControllerIT.java @@ -23,7 +23,6 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; - @SpringBootTest @AutoConfigureMockMvc @Transactional From 08e32ed554aecded4255ddd2b5fd504340c24571 Mon Sep 17 00:00:00 2001 From: Fiona Friberg Date: Mon, 27 Apr 2026 15:59:15 +0200 Subject: [PATCH 8/9] fix: set uploadedBy for demo attachments --- src/main/java/org/example/alfs/services/DemoDataService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/example/alfs/services/DemoDataService.java b/src/main/java/org/example/alfs/services/DemoDataService.java index b8b99b5..d75f54d 100644 --- a/src/main/java/org/example/alfs/services/DemoDataService.java +++ b/src/main/java/org/example/alfs/services/DemoDataService.java @@ -177,6 +177,7 @@ private void addAttachment(Ticket ticket, String fileName, User actor) { a.setTicket(ticket); a.setFileName(fileName); a.setS3Key("demo/" + UUID.randomUUID()); + a.setUploadedBy(actor); attachmentRepository.save(a); From 9e93570adfcabbaced312317f31ca270916db48e Mon Sep 17 00:00:00 2001 From: Simon Forsberg Date: Tue, 28 Apr 2026 10:22:03 +0200 Subject: [PATCH 9/9] Refactor `TicketControllerIT` for readability and consistency; fix minor formatting issue in `TicketViewDTO`. --- .../alfs/dto/ticket/TicketViewDTO.java | 2 +- .../alfs/integration/TicketControllerIT.java | 365 +++++++++--------- 2 files changed, 187 insertions(+), 180 deletions(-) diff --git a/src/main/java/org/example/alfs/dto/ticket/TicketViewDTO.java b/src/main/java/org/example/alfs/dto/ticket/TicketViewDTO.java index c6876a7..cbfe88e 100644 --- a/src/main/java/org/example/alfs/dto/ticket/TicketViewDTO.java +++ b/src/main/java/org/example/alfs/dto/ticket/TicketViewDTO.java @@ -28,7 +28,7 @@ public class TicketViewDTO { private String assignedInvestigatorName; private static final DateTimeFormatter DISPLAY_FORMATTER = - DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm", Locale.forLanguageTag("sv-SE")); + DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm", Locale.forLanguageTag("sv-SE")); public String getFormattedCreatedAt() { if (createdAt == null) return ""; diff --git a/src/test/java/org/example/alfs/integration/TicketControllerIT.java b/src/test/java/org/example/alfs/integration/TicketControllerIT.java index 683195f..9e4c282 100644 --- a/src/test/java/org/example/alfs/integration/TicketControllerIT.java +++ b/src/test/java/org/example/alfs/integration/TicketControllerIT.java @@ -1,5 +1,10 @@ package org.example.alfs.integration; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + import org.example.alfs.dto.ticket.TicketCreateDTO; import org.example.alfs.entities.User; import org.example.alfs.enums.Role; @@ -17,197 +22,199 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - @SpringBootTest @AutoConfigureMockMvc @Transactional @DisplayName("TicketController Integration Tests") class TicketControllerIT { - @Autowired - private MockMvc mockMvc; - - @Autowired - private TicketService ticketService; - @Autowired - private UserRepository userRepository; - @Autowired - private TicketRepository ticketRepository; - - private Long ticketId; - private User admin; - private User investigator; - private User reporter; - - @BeforeEach - void setUp() { - admin = new User(); - admin.setRole(Role.ADMIN); - admin.setUsername("admin"); - admin.setPasswordHash("hashed-password"); - admin = userRepository.save(admin); - - investigator = new User(); - investigator.setRole(Role.INVESTIGATOR); - investigator.setUsername("investigator"); - investigator.setPasswordHash("hashed-password"); - investigator = userRepository.save(investigator); - - reporter = new User(); - reporter.setRole(Role.REPORTER); - reporter.setUsername("reporter"); - reporter.setPasswordHash("hashed-password"); - reporter = userRepository.save(reporter); - - TicketCreateDTO dto = new TicketCreateDTO(); - dto.setTitle("Test"); - dto.setDescription("Test"); - ticketId = ticketService.createNewTicket(dto).getId(); + @Autowired private MockMvc mockMvc; + + @Autowired private TicketService ticketService; + @Autowired private UserRepository userRepository; + @Autowired private TicketRepository ticketRepository; + + private Long ticketId; + private User admin; + private User investigator; + private User reporter; + + @BeforeEach + void setUp() { + admin = new User(); + admin.setRole(Role.ADMIN); + admin.setUsername("admin"); + admin.setPasswordHash("hashed-password"); + admin = userRepository.save(admin); + + investigator = new User(); + investigator.setRole(Role.INVESTIGATOR); + investigator.setUsername("investigator"); + investigator.setPasswordHash("hashed-password"); + investigator = userRepository.save(investigator); + + reporter = new User(); + reporter.setRole(Role.REPORTER); + reporter.setUsername("reporter"); + reporter.setPasswordHash("hashed-password"); + reporter = userRepository.save(reporter); + + TicketCreateDTO dto = new TicketCreateDTO(); + dto.setTitle("Test"); + dto.setDescription("Test"); + ticketId = ticketService.createNewTicket(dto).getId(); + } + + @Nested + @DisplayName("Anonymous Reporter") + class AnonymousReporter { + + @Test + @DisplayName("Anonymous reporter can access create form") + void anonymousReporter_canAccessCreateForm() throws Exception { + mockMvc + .perform(get("/tickets/create")) + .andExpect(status().isOk()) + .andExpect(view().name("create")) + .andExpect(model().attributeExists("ticket")); } - @Nested - @DisplayName("Anonymous Reporter") - class AnonymousReporter { - - @Test - @DisplayName("Anonymous reporter can access create form") - void anonymousReporter_canAccessCreateForm() throws Exception { - mockMvc.perform(get("/tickets/create")) - .andExpect(status().isOk()) - .andExpect(view().name("create")) - .andExpect(model().attributeExists("ticket")); - } - - @Test - @DisplayName("Anonymous reporter can view a created ticket with valid token") - void anonymousReporter_validToken_returnsView() throws Exception { - var ticket = ticketRepository.findById(ticketId).orElseThrow(); - - mockMvc.perform(get("/tickets/token/" + ticket.getReporterToken())) - .andExpect(status().isOk()) - .andExpect(view().name("view")) - .andExpect(model().attributeExists("ticket")) - .andExpect(model().attributeExists("comments")) - .andExpect(model().attributeExists("attachments")) - .andExpect(model().attributeExists("auditLogs")) - .andExpect(model().attributeExists("investigators")) - .andExpect(model().attributeExists("accessToken")); - } - - @Test - @DisplayName("Anonymous reporter is redirected when token is invalid") - void anonymousReporter_invalidToken_redirectsToLogin() throws Exception { - mockMvc.perform(get("/tickets/token/invalid-token")) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/login?tokenError=true")); - } - - @Test - @DisplayName("Anonymous reporter submits valid ticket and is redirected to ticket-created") - void anonymousReporter_validPost_redirectsToTicketCreated() throws Exception { - mockMvc.perform(post("/tickets/create") - .param("title", "Test title") - .param("description", "Test description")) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrlPattern("/tickets/ticket-created?token=*")); - } - - @Test - @DisplayName("Anonymous reporter submits blank form and sees create page again") - void anonymousReporter_blankPost_returnsCreateForm() throws Exception { - mockMvc.perform(post("/tickets/create") - .param("title", "") - .param("description", "")) - .andExpect(status().isOk()) - .andExpect(view().name("create")) - .andExpect(model().attributeHasFieldErrors("ticket", "title", "description")); - } + @Test + @DisplayName("Anonymous reporter can view a created ticket with valid token") + void anonymousReporter_validToken_returnsView() throws Exception { + var ticket = ticketRepository.findById(ticketId).orElseThrow(); + + mockMvc + .perform(get("/tickets/token/" + ticket.getReporterToken())) + .andExpect(status().isOk()) + .andExpect(view().name("view")) + .andExpect(model().attributeExists("ticket")) + .andExpect(model().attributeExists("comments")) + .andExpect(model().attributeExists("attachments")) + .andExpect(model().attributeExists("auditLogs")) + .andExpect(model().attributeExists("investigators")) + .andExpect(model().attributeExists("accessToken")); } - @Nested - @DisplayName("Authenticated reporter") - class AuthenticatedReporter { - - @Test - @WithMockUser(username = "reporter", roles = "REPORTER") - @DisplayName("Reporter can view their own tickets") - void reporter_canViewOwnTickets() throws Exception { - mockMvc.perform(get("/tickets/my")) - .andExpect(status().isOk()) - .andExpect(view().name("my-tickets")) - .andExpect(model().attributeExists("tickets")); - } + @Test + @DisplayName("Anonymous reporter is redirected when token is invalid") + void anonymousReporter_invalidToken_redirectsToLogin() throws Exception { + mockMvc + .perform(get("/tickets/token/invalid-token")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?tokenError=true")); } - @Nested - @DisplayName("Authenticated investigator") - class AuthenticatedInvestigator { - - @Test - @WithMockUser(username = "investigator", roles = "INVESTIGATOR") - @DisplayName("Investigator can view their assigned tickets") - void investigator_canViewAssignedTickets() throws Exception { - mockMvc.perform(get("/tickets/assigned")) - .andExpect(status().isOk()) - .andExpect(view().name("assigned-tickets")) - .andExpect(model().attributeExists("tickets")); - } + @Test + @DisplayName("Anonymous reporter submits valid ticket and is redirected to ticket-created") + void anonymousReporter_validPost_redirectsToTicketCreated() throws Exception { + mockMvc + .perform( + post("/tickets/create") + .param("title", "Test title") + .param("description", "Test description")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern("/tickets/ticket-created?token=*")); + } + + @Test + @DisplayName("Anonymous reporter submits blank form and sees create page again") + void anonymousReporter_blankPost_returnsCreateForm() throws Exception { + mockMvc + .perform(post("/tickets/create").param("title", "").param("description", "")) + .andExpect(status().isOk()) + .andExpect(view().name("create")) + .andExpect(model().attributeHasFieldErrors("ticket", "title", "description")); + } + } + + @Nested + @DisplayName("Authenticated reporter") + class AuthenticatedReporter { + + @Test + @WithMockUser(username = "reporter", roles = "REPORTER") + @DisplayName("Reporter can view their own tickets") + void reporter_canViewOwnTickets() throws Exception { + mockMvc + .perform(get("/tickets/my")) + .andExpect(status().isOk()) + .andExpect(view().name("my-tickets")) + .andExpect(model().attributeExists("tickets")); + } + } + + @Nested + @DisplayName("Authenticated investigator") + class AuthenticatedInvestigator { + + @Test + @WithMockUser(username = "investigator", roles = "INVESTIGATOR") + @DisplayName("Investigator can view their assigned tickets") + void investigator_canViewAssignedTickets() throws Exception { + mockMvc + .perform(get("/tickets/assigned")) + .andExpect(status().isOk()) + .andExpect(view().name("assigned-tickets")) + .andExpect(model().attributeExists("tickets")); + } + } + + @Nested + @DisplayName("Admin") + class Admin { + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + @DisplayName("Admin can view ticket by id") + void admin_canViewTicketById() throws Exception { + mockMvc + .perform(get("/tickets/{id}", ticketId)) + .andExpect(status().isOk()) + .andExpect(view().name("view")) + .andExpect(model().attributeExists("ticket")) + .andExpect(model().attributeExists("comments")) + .andExpect(model().attributeExists("attachments")) + .andExpect(model().attributeExists("auditLogs")) + .andExpect(model().attributeExists("investigators")); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + @DisplayName("Admin can assign an investigator to a ticket") + void admin_canAssignInvestigator() throws Exception { + mockMvc + .perform( + post("/tickets/{id}/assign", ticketId) + .param("investigatorId", investigator.getId().toString())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/tickets/" + ticketId)); + } + + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + @DisplayName("Admin can update status on a ticket") + void admin_canUpdateTicketStatus() throws Exception { + ticketService.assignInvestigator(ticketId, investigator.getId()); + + mockMvc + .perform(post("/tickets/{id}/status", ticketId).param("status", "RESOLVED")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/tickets/" + ticketId)); } - @Nested - @DisplayName("Admin") - class Admin { - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - @DisplayName("Admin can view ticket by id") - void admin_canViewTicketById() throws Exception { - mockMvc.perform(get("/tickets/{id}", ticketId)) - .andExpect(status().isOk()) - .andExpect(view().name("view")) - .andExpect(model().attributeExists("ticket")) - .andExpect(model().attributeExists("comments")) - .andExpect(model().attributeExists("attachments")) - .andExpect(model().attributeExists("auditLogs")) - .andExpect(model().attributeExists("investigators")); - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - @DisplayName("Admin can assign an investigator to a ticket") - void admin_canAssignInvestigator() throws Exception { - mockMvc.perform(post("/tickets/{id}/assign", ticketId) - .param("investigatorId", investigator.getId().toString())) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/tickets/" + ticketId)); - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - @DisplayName("Admin can update status on a ticket") - void admin_canUpdateTicketStatus() throws Exception { - ticketService.assignInvestigator(ticketId, investigator.getId()); - - mockMvc.perform(post("/tickets/{id}/status", ticketId) - .param("status", "RESOLVED")) - .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/tickets/" + ticketId)); - } - - @Test - @WithMockUser(username = "admin", roles = "ADMIN") - @DisplayName("Admin cannot perform invalid status transition") - void admin_cannotPerformInvalidStatusTransition() throws Exception { - var ticket = ticketRepository.findById(ticketId).orElseThrow(); - assertEquals(org.example.alfs.enums.TicketStatus.OPEN, ticket.getStatus()); - - mockMvc.perform(post("/tickets/{id}/status", ticketId) - .param("status", "RESOLVED")) // Transition OPEN -> RESOLVED is invalid - .andExpect(status().isBadRequest()); - } + @Test + @WithMockUser(username = "admin", roles = "ADMIN") + @DisplayName("Admin cannot perform invalid status transition") + void admin_cannotPerformInvalidStatusTransition() throws Exception { + var ticket = ticketRepository.findById(ticketId).orElseThrow(); + assertEquals(org.example.alfs.enums.TicketStatus.OPEN, ticket.getStatus()); + + mockMvc + .perform( + post("/tickets/{id}/status", ticketId) + .param("status", "RESOLVED")) // Transition OPEN -> RESOLVED is invalid + .andExpect(status().isBadRequest()); } -} \ No newline at end of file + } +}