From a8bd1893096b886b8d7f5a6a953b0a5b9852b345 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Sat, 25 Jul 2026 22:04:00 +0530 Subject: [PATCH] feat: implement comments on files and folders with @mentions and notification support --- backend/mvnw.cmd | 2 +- .../controller/CommentController.java | 55 +++++ .../controller/NotificationController.java | 50 ++++ .../main/java/cloudpage/dto/CommentDto.java | 20 ++ .../cloudpage/dto/CreateCommentRequest.java | 18 ++ .../java/cloudpage/dto/NotificationDto.java | 22 ++ .../main/java/cloudpage/model/Comment.java | 44 ++++ .../java/cloudpage/model/Notification.java | 50 ++++ .../repository/CommentRepository.java | 13 + .../repository/NotificationRepository.java | 18 ++ .../cloudpage/service/CommentService.java | 181 ++++++++++++++ .../service/NotificationService.java | 92 +++++++ .../controller/CommentControllerTest.java | 103 ++++++++ .../NotificationControllerTest.java | 79 ++++++ .../cloudpage/service/CommentServiceTest.java | 225 ++++++++++++++++++ .../service/NotificationServiceTest.java | 126 ++++++++++ 16 files changed, 1097 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/cloudpage/controller/CommentController.java create mode 100644 backend/src/main/java/cloudpage/controller/NotificationController.java create mode 100644 backend/src/main/java/cloudpage/dto/CommentDto.java create mode 100644 backend/src/main/java/cloudpage/dto/CreateCommentRequest.java create mode 100644 backend/src/main/java/cloudpage/dto/NotificationDto.java create mode 100644 backend/src/main/java/cloudpage/model/Comment.java create mode 100644 backend/src/main/java/cloudpage/model/Notification.java create mode 100644 backend/src/main/java/cloudpage/repository/CommentRepository.java create mode 100644 backend/src/main/java/cloudpage/repository/NotificationRepository.java create mode 100644 backend/src/main/java/cloudpage/service/CommentService.java create mode 100644 backend/src/main/java/cloudpage/service/NotificationService.java create mode 100644 backend/src/test/java/cloudpage/controller/CommentControllerTest.java create mode 100644 backend/src/test/java/cloudpage/controller/NotificationControllerTest.java create mode 100644 backend/src/test/java/cloudpage/service/CommentServiceTest.java create mode 100644 backend/src/test/java/cloudpage/service/NotificationServiceTest.java diff --git a/backend/mvnw.cmd b/backend/mvnw.cmd index 249bdf38..71bb0dc2 100644 --- a/backend/mvnw.cmd +++ b/backend/mvnw.cmd @@ -40,7 +40,7 @@ @SET __MVNW_ARG0_NAME__= @SET MVNW_USERNAME= @SET MVNW_PASSWORD= -@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) @echo Cannot start maven from wrapper >&2 && exit /b 1 @GOTO :EOF : end batch / begin powershell #> diff --git a/backend/src/main/java/cloudpage/controller/CommentController.java b/backend/src/main/java/cloudpage/controller/CommentController.java new file mode 100644 index 00000000..aa1933f3 --- /dev/null +++ b/backend/src/main/java/cloudpage/controller/CommentController.java @@ -0,0 +1,55 @@ +package cloudpage.controller; + +import cloudpage.dto.CommentDto; +import cloudpage.dto.CreateCommentRequest; +import cloudpage.service.CommentService; +import cloudpage.service.UserService; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Controller for CRUD actions on comments left on files/folders. */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/comments") +public class CommentController { + + private final CommentService commentService; + private final UserService userService; + + /** Adds a new comment on a file or folder. */ + @PostMapping + public ResponseEntity addComment(@Valid @RequestBody CreateCommentRequest request) + throws IOException { + var user = userService.getCurrentUser(); + CommentDto comment = commentService.addComment(user, request); + return ResponseEntity.ok(comment); + } + + /** Lists all comments on a specific file or folder. */ + @GetMapping + public ResponseEntity> listComments( + @RequestParam String ownerUsername, @RequestParam String filePath) throws IOException { + var user = userService.getCurrentUser(); + List comments = commentService.listComments(user, ownerUsername, filePath); + return ResponseEntity.ok(comments); + } + + /** Deletes a comment by ID. */ + @DeleteMapping("/{id}") + public ResponseEntity deleteComment(@PathVariable Long id) { + var user = userService.getCurrentUser(); + commentService.deleteComment(user, id); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/cloudpage/controller/NotificationController.java b/backend/src/main/java/cloudpage/controller/NotificationController.java new file mode 100644 index 00000000..049ed15e --- /dev/null +++ b/backend/src/main/java/cloudpage/controller/NotificationController.java @@ -0,0 +1,50 @@ +package cloudpage.controller; + +import cloudpage.dto.NotificationDto; +import cloudpage.service.NotificationService; +import cloudpage.service.UserService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Controller for retrieving and managing notifications. */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationController { + + private final NotificationService notificationService; + private final UserService userService; + + /** Lists notifications for the current authenticated user. */ + @GetMapping + public ResponseEntity> listNotifications( + @RequestParam(required = false, defaultValue = "false") boolean unreadOnly) { + var user = userService.getCurrentUser(); + List notifications = + notificationService.getNotificationsForUser(user.getUsername(), unreadOnly); + return ResponseEntity.ok(notifications); + } + + /** Marks a notification as read. */ + @PostMapping("/{id}/read") + public ResponseEntity markAsRead(@PathVariable Long id) { + var user = userService.getCurrentUser(); + notificationService.markAsRead(user.getUsername(), id); + return ResponseEntity.ok().build(); + } + + /** Marks all notifications for the current user as read. */ + @PostMapping("/read-all") + public ResponseEntity markAllAsRead() { + var user = userService.getCurrentUser(); + notificationService.markAllAsRead(user.getUsername()); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/cloudpage/dto/CommentDto.java b/backend/src/main/java/cloudpage/dto/CommentDto.java new file mode 100644 index 00000000..cff3ca5f --- /dev/null +++ b/backend/src/main/java/cloudpage/dto/CommentDto.java @@ -0,0 +1,20 @@ +package cloudpage.dto; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class CommentDto { + private Long id; + private String ownerUsername; + private String filePath; + private String authorUsername; + private String content; + private Instant createdAt; +} diff --git a/backend/src/main/java/cloudpage/dto/CreateCommentRequest.java b/backend/src/main/java/cloudpage/dto/CreateCommentRequest.java new file mode 100644 index 00000000..25fc93db --- /dev/null +++ b/backend/src/main/java/cloudpage/dto/CreateCommentRequest.java @@ -0,0 +1,18 @@ +package cloudpage.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CreateCommentRequest { + @NotBlank private String ownerUsername; + + @NotBlank private String filePath; + + @NotBlank + @Size(max = 8192) + private String content; +} diff --git a/backend/src/main/java/cloudpage/dto/NotificationDto.java b/backend/src/main/java/cloudpage/dto/NotificationDto.java new file mode 100644 index 00000000..32b958d2 --- /dev/null +++ b/backend/src/main/java/cloudpage/dto/NotificationDto.java @@ -0,0 +1,22 @@ +package cloudpage.dto; + +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class NotificationDto { + private Long id; + private String recipientUsername; + private String type; + private String message; + private String filePath; + private String ownerUsername; + private boolean read; + private Instant createdAt; +} diff --git a/backend/src/main/java/cloudpage/model/Comment.java b/backend/src/main/java/cloudpage/model/Comment.java new file mode 100644 index 00000000..014e1623 --- /dev/null +++ b/backend/src/main/java/cloudpage/model/Comment.java @@ -0,0 +1,44 @@ +package cloudpage.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.Getter; +import lombok.Setter; + +/** Entity representing a comment left on a file or folder in a user's cloud storage. */ +@Entity +@Table( + name = "comments", + indexes = { + @Index(name = "idx_comment_owner_path", columnList = "owner_username, file_path"), + @Index(name = "idx_comment_created_at", columnList = "created_at") + }) +@Getter +@Setter +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "owner_username", nullable = false) + private String ownerUsername; + + @Column(name = "file_path", nullable = false, length = 4096) + private String filePath; + + @Column(name = "author_username", nullable = false) + private String authorUsername; + + @Column(nullable = false, length = 8192) + private String content; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; +} diff --git a/backend/src/main/java/cloudpage/model/Notification.java b/backend/src/main/java/cloudpage/model/Notification.java new file mode 100644 index 00000000..d95e471d --- /dev/null +++ b/backend/src/main/java/cloudpage/model/Notification.java @@ -0,0 +1,50 @@ +package cloudpage.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.Getter; +import lombok.Setter; + +/** Entity representing user notifications for mentions or other service events. */ +@Entity +@Table( + name = "notifications", + indexes = { + @Index(name = "idx_notification_recipient", columnList = "recipient_username"), + @Index(name = "idx_notification_created_at", columnList = "created_at") + }) +@Getter +@Setter +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "recipient_username", nullable = false) + private String recipientUsername; + + @Column(nullable = false) + private String type; + + @Column(nullable = false, length = 4096) + private String message; + + @Column(name = "file_path", nullable = false, length = 4096) + private String filePath; + + @Column(name = "owner_username", nullable = false) + private String ownerUsername; + + @Column(name = "is_read", nullable = false) + private boolean read; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; +} diff --git a/backend/src/main/java/cloudpage/repository/CommentRepository.java b/backend/src/main/java/cloudpage/repository/CommentRepository.java new file mode 100644 index 00000000..db1cdcf8 --- /dev/null +++ b/backend/src/main/java/cloudpage/repository/CommentRepository.java @@ -0,0 +1,13 @@ +package cloudpage.repository; + +import cloudpage.model.Comment; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** Repository interface for comments. */ +@Repository +public interface CommentRepository extends JpaRepository { + List findByOwnerUsernameAndFilePathOrderByCreatedAtAsc( + String ownerUsername, String filePath); +} diff --git a/backend/src/main/java/cloudpage/repository/NotificationRepository.java b/backend/src/main/java/cloudpage/repository/NotificationRepository.java new file mode 100644 index 00000000..0a7dc01e --- /dev/null +++ b/backend/src/main/java/cloudpage/repository/NotificationRepository.java @@ -0,0 +1,18 @@ +package cloudpage.repository; + +import cloudpage.model.Notification; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** Repository interface for user notifications. */ +@Repository +public interface NotificationRepository extends JpaRepository { + List findByRecipientUsernameOrderByCreatedAtDesc(String recipientUsername); + + List findByRecipientUsernameAndReadOrderByCreatedAtDesc( + String recipientUsername, boolean read); + + Optional findByIdAndRecipientUsername(Long id, String recipientUsername); +} diff --git a/backend/src/main/java/cloudpage/service/CommentService.java b/backend/src/main/java/cloudpage/service/CommentService.java new file mode 100644 index 00000000..3af2b755 --- /dev/null +++ b/backend/src/main/java/cloudpage/service/CommentService.java @@ -0,0 +1,181 @@ +package cloudpage.service; + +import cloudpage.dto.CommentDto; +import cloudpage.dto.CreateCommentRequest; +import cloudpage.exceptions.FileNotFoundException; +import cloudpage.exceptions.ResourceNotFoundException; +import cloudpage.exceptions.UnauthorizedAccessException; +import cloudpage.model.Comment; +import cloudpage.model.User; +import cloudpage.repository.CommentRepository; +import cloudpage.repository.UserRepository; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Service handling comments creation, listing, deletion, and sending @mention notifications. */ +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final UserRepository userRepository; + private final NotificationService notificationService; + private final FolderService folderService; + + private static final Pattern MENTION_PATTERN = Pattern.compile("@([a-zA-Z0-9_\\-\\.]+)"); + + /** Adds a new comment on a file or folder and triggers notifications for valid @mentions. */ + @Transactional + public CommentDto addComment(User author, CreateCommentRequest request) throws IOException { + User owner = + userRepository + .findByUsername(request.getOwnerUsername()) + .orElseThrow( + () -> + new ResourceNotFoundException("User", "Username", request.getOwnerUsername())); + + // Check if the author has access to the owner's workspace/item + validateAccess(author, owner.getUsername()); + + // Validate that the file/folder actually exists in the workspace + Path fullPath = Paths.get(owner.getRootFolderPath(), request.getFilePath()).normalize(); + folderService.validatePath(owner.getRootFolderPath(), fullPath); + + if (!Files.exists(fullPath)) { + throw new FileNotFoundException( + "Target file or folder not found at path: " + request.getFilePath()); + } + + Comment comment = new Comment(); + comment.setOwnerUsername(owner.getUsername()); + comment.setFilePath(request.getFilePath()); + comment.setAuthorUsername(author.getUsername()); + comment.setContent(request.getContent()); + comment.setCreatedAt(Instant.now()); + comment.setId(null); + + Comment saved = commentRepository.save(comment); + + // Parse and handle @mentions + processMentions( + author.getUsername(), request.getContent(), request.getFilePath(), owner.getUsername()); + + return mapToDto(saved); + } + + /** Lists all comments on a specific file or folder. */ + @Transactional(readOnly = true) + public List listComments(User currentUser, String ownerUsername, String filePath) + throws IOException { + User owner = + userRepository + .findByUsername(ownerUsername) + .orElseThrow(() -> new ResourceNotFoundException("User", "Username", ownerUsername)); + + // Check if the current user has access to the owner's workspace + validateAccess(currentUser, owner.getUsername()); + + // Validate that the file/folder actually exists + Path fullPath = Paths.get(owner.getRootFolderPath(), filePath).normalize(); + folderService.validatePath(owner.getRootFolderPath(), fullPath); + + if (!Files.exists(fullPath)) { + throw new FileNotFoundException("Target file or folder not found at path: " + filePath); + } + + List comments = + commentRepository.findByOwnerUsernameAndFilePathOrderByCreatedAtAsc( + owner.getUsername(), filePath); + + return comments.stream().map(this::mapToDto).collect(Collectors.toList()); + } + + /** Deletes a comment. Authorized for comment author or workspace owner. */ + @Transactional + public void deleteComment(User currentUser, Long commentId) { + Comment comment = + commentRepository + .findById(commentId) + .orElseThrow(() -> new ResourceNotFoundException("Comment", "Id", commentId)); + + // Access control: only the comment author or the workspace owner can delete the comment + if (!comment.getAuthorUsername().equalsIgnoreCase(currentUser.getUsername()) + && !comment.getOwnerUsername().equalsIgnoreCase(currentUser.getUsername())) { + throw new UnauthorizedAccessException("You are not authorized to delete this comment."); + } + + commentRepository.delete(comment); + } + + /** Validates access of currentUser to owner's workspace. */ + public void validateAccess(User currentUser, String ownerUsername) { + if (currentUser.getUsername().equalsIgnoreCase(ownerUsername)) { + return; + } + // Access control: only the workspace owner can access workspace elements currently. + // In the future, if internal sharing is implemented, sharing permissions would be checked here. + throw new UnauthorizedAccessException("You do not have access to this workspace file/folder."); + } + + private void processMentions( + String authorUsername, String content, String filePath, String ownerUsername) { + if (content == null || content.isBlank()) { + return; + } + + Matcher matcher = MENTION_PATTERN.matcher(content); + Set mentionedUsernames = new HashSet<>(); + while (matcher.find()) { + mentionedUsernames.add(matcher.group(1)); + } + + String itemName = getItemName(filePath); + for (String username : mentionedUsernames) { + // Do not notify self-mentions + if (username.equalsIgnoreCase(authorUsername)) { + continue; + } + + userRepository + .findByUsername(username) + .ifPresent( + recipient -> { + String message = + String.format("%s mentioned you in a comment on %s", authorUsername, itemName); + notificationService.sendNotification( + recipient.getUsername(), "MENTION", message, filePath, ownerUsername); + }); + } + } + + private String getItemName(String filePath) { + if (filePath == null || filePath.isBlank() || ".".equals(filePath)) { + return "root folder"; + } + Path path = Paths.get(filePath); + Path fileName = path.getFileName(); + return fileName != null ? fileName.toString() : filePath; + } + + private CommentDto mapToDto(Comment comment) { + return new CommentDto( + comment.getId(), + comment.getOwnerUsername(), + comment.getFilePath(), + comment.getAuthorUsername(), + comment.getContent(), + comment.getCreatedAt()); + } +} diff --git a/backend/src/main/java/cloudpage/service/NotificationService.java b/backend/src/main/java/cloudpage/service/NotificationService.java new file mode 100644 index 00000000..fa26d6aa --- /dev/null +++ b/backend/src/main/java/cloudpage/service/NotificationService.java @@ -0,0 +1,92 @@ +package cloudpage.service; + +import cloudpage.dto.NotificationDto; +import cloudpage.exceptions.ResourceNotFoundException; +import cloudpage.model.Notification; +import cloudpage.repository.NotificationRepository; +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Service handling notification operations like sending notifications, marking them as read. */ +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationRepository notificationRepository; + + /** Sends/creates a notification for a recipient user. */ + @Transactional + public void sendNotification( + String recipientUsername, + String type, + String message, + String filePath, + String ownerUsername) { + Notification notification = new Notification(); + notification.setRecipientUsername(recipientUsername); + notification.setType(type); + notification.setMessage(message); + notification.setFilePath(filePath); + notification.setOwnerUsername(ownerUsername); + notification.setRead(false); + notification.setCreatedAt(Instant.now()); + notification.setId(null); + + notificationRepository.save(notification); + } + + /** Lists notifications for the given user, optionally filtering by unread state. */ + @Transactional(readOnly = true) + public List getNotificationsForUser(String username, Boolean unreadOnly) { + List notifications; + if (Boolean.TRUE.equals(unreadOnly)) { + notifications = + notificationRepository.findByRecipientUsernameAndReadOrderByCreatedAtDesc( + username, false); + } else { + notifications = notificationRepository.findByRecipientUsernameOrderByCreatedAtDesc(username); + } + + return notifications.stream().map(this::mapToDto).collect(Collectors.toList()); + } + + /** Marks a specific notification as read. */ + @Transactional + public void markAsRead(String username, Long notificationId) { + Notification notification = + notificationRepository + .findByIdAndRecipientUsername(notificationId, username) + .orElseThrow( + () -> + new ResourceNotFoundException("Notification", "Id", notificationId.toString())); + notification.setRead(true); + notificationRepository.save(notification); + } + + /** Marks all notifications for a user as read. */ + @Transactional + public void markAllAsRead(String username) { + List unread = + notificationRepository.findByRecipientUsernameAndReadOrderByCreatedAtDesc(username, false); + for (Notification n : unread) { + n.setRead(true); + } + notificationRepository.saveAll(unread); + } + + private NotificationDto mapToDto(Notification entity) { + return new NotificationDto( + entity.getId(), + entity.getRecipientUsername(), + entity.getType(), + entity.getMessage(), + entity.getFilePath(), + entity.getOwnerUsername(), + entity.isRead(), + entity.getCreatedAt()); + } +} diff --git a/backend/src/test/java/cloudpage/controller/CommentControllerTest.java b/backend/src/test/java/cloudpage/controller/CommentControllerTest.java new file mode 100644 index 00000000..50882ecc --- /dev/null +++ b/backend/src/test/java/cloudpage/controller/CommentControllerTest.java @@ -0,0 +1,103 @@ +package cloudpage.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cloudpage.dto.CommentDto; +import cloudpage.dto.CreateCommentRequest; +import cloudpage.model.User; +import cloudpage.ratelimit.RateLimitFilter; +import cloudpage.security.JwtAuthFilter; +import cloudpage.security.JwtUtil; +import cloudpage.service.CommentService; +import cloudpage.service.UserService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(CommentController.class) +@AutoConfigureMockMvc(addFilters = false) +class CommentControllerTest { + + @Autowired private MockMvc mockMvc; + private final ObjectMapper objectMapper = + new ObjectMapper().registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); + + @MockitoBean private CommentService commentService; + @MockitoBean private UserService userService; + @MockitoBean private JwtAuthFilter jwtAuthFilter; + @MockitoBean private JwtUtil jwtUtil; + @MockitoBean private RateLimitFilter rateLimitFilter; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setId("user-1"); + testUser.setUsername("testuser"); + testUser.setRootFolderPath("C:/fake/path"); + when(userService.getCurrentUser()).thenReturn(testUser); + } + + @Test + void addComment_validRequest_returns200() throws Exception { + CreateCommentRequest request = new CreateCommentRequest(); + request.setOwnerUsername("testuser"); + request.setFilePath("file.txt"); + request.setContent("This is a comment"); + + CommentDto response = + new CommentDto(1L, "testuser", "file.txt", "testuser", "This is a comment", Instant.now()); + + when(commentService.addComment(eq(testUser), any(CreateCommentRequest.class))) + .thenReturn(response); + + mockMvc + .perform( + post("/api/comments") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.content").value("This is a comment")); + } + + @Test + void listComments_validRequest_returns200() throws Exception { + CommentDto comment = + new CommentDto(1L, "testuser", "file.txt", "testuser", "This is a comment", Instant.now()); + + when(commentService.listComments(eq(testUser), eq("testuser"), eq("file.txt"))) + .thenReturn(Collections.singletonList(comment)); + + mockMvc + .perform( + get("/api/comments").param("ownerUsername", "testuser").param("filePath", "file.txt")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1L)) + .andExpect(jsonPath("$[0].content").value("This is a comment")); + } + + @Test + void deleteComment_validRequest_returns200() throws Exception { + mockMvc.perform(delete("/api/comments/1")).andExpect(status().isOk()); + + verify(commentService).deleteComment(testUser, 1L); + } +} diff --git a/backend/src/test/java/cloudpage/controller/NotificationControllerTest.java b/backend/src/test/java/cloudpage/controller/NotificationControllerTest.java new file mode 100644 index 00000000..c0bccee3 --- /dev/null +++ b/backend/src/test/java/cloudpage/controller/NotificationControllerTest.java @@ -0,0 +1,79 @@ +package cloudpage.controller; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import cloudpage.dto.NotificationDto; +import cloudpage.model.User; +import cloudpage.ratelimit.RateLimitFilter; +import cloudpage.security.JwtAuthFilter; +import cloudpage.security.JwtUtil; +import cloudpage.service.NotificationService; +import cloudpage.service.UserService; +import java.time.Instant; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(NotificationController.class) +@AutoConfigureMockMvc(addFilters = false) +class NotificationControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private NotificationService notificationService; + @MockitoBean private UserService userService; + @MockitoBean private JwtAuthFilter jwtAuthFilter; + @MockitoBean private JwtUtil jwtUtil; + @MockitoBean private RateLimitFilter rateLimitFilter; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setId("user-1"); + testUser.setUsername("testuser"); + testUser.setRootFolderPath("C:/fake/path"); + when(userService.getCurrentUser()).thenReturn(testUser); + } + + @Test + void listNotifications_returns200() throws Exception { + NotificationDto n = + new NotificationDto( + 1L, "testuser", "MENTION", "message", "doc.pdf", "owner", false, Instant.now()); + + when(notificationService.getNotificationsForUser("testuser", false)) + .thenReturn(Collections.singletonList(n)); + + mockMvc + .perform(get("/api/notifications").param("unreadOnly", "false")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(1L)) + .andExpect(jsonPath("$[0].message").value("message")); + } + + @Test + void markAsRead_returns200() throws Exception { + mockMvc.perform(post("/api/notifications/1/read")).andExpect(status().isOk()); + + verify(notificationService).markAsRead("testuser", 1L); + } + + @Test + void markAllAsRead_returns200() throws Exception { + mockMvc.perform(post("/api/notifications/read-all")).andExpect(status().isOk()); + + verify(notificationService).markAllAsRead("testuser"); + } +} diff --git a/backend/src/test/java/cloudpage/service/CommentServiceTest.java b/backend/src/test/java/cloudpage/service/CommentServiceTest.java new file mode 100644 index 00000000..9e03e1a6 --- /dev/null +++ b/backend/src/test/java/cloudpage/service/CommentServiceTest.java @@ -0,0 +1,225 @@ +package cloudpage.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cloudpage.dto.CommentDto; +import cloudpage.dto.CreateCommentRequest; +import cloudpage.exceptions.FileNotFoundException; +import cloudpage.exceptions.UnauthorizedAccessException; +import cloudpage.model.Comment; +import cloudpage.model.User; +import cloudpage.repository.CommentRepository; +import cloudpage.repository.UserRepository; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @Mock private CommentRepository commentRepository; + @Mock private UserRepository userRepository; + @Mock private NotificationService notificationService; + @Mock private FolderService folderService; + + @TempDir Path tempDir; + + private CommentService service; + private User alice; + private User bob; + + @BeforeEach + void setUp() { + service = + new CommentService(commentRepository, userRepository, notificationService, folderService); + + alice = new User(); + alice.setId("user-alice"); + alice.setUsername("alice"); + alice.setRootFolderPath(tempDir.toString()); + + bob = new User(); + bob.setId("user-bob"); + bob.setUsername("bob"); + bob.setRootFolderPath(tempDir.toString()); + } + + @Test + void addComment_success_withMentions() throws IOException { + // Create physical file + Path targetFile = Files.createFile(tempDir.resolve("document.pdf")); + + CreateCommentRequest request = new CreateCommentRequest(); + request.setOwnerUsername("alice"); + request.setFilePath("document.pdf"); + request.setContent("Hey @bob check out this file!"); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(alice)); + when(userRepository.findByUsername("bob")).thenReturn(Optional.of(bob)); + doNothing().when(folderService).validatePath(eq(alice.getRootFolderPath()), any(Path.class)); + + Comment savedComment = new Comment(); + savedComment.setId(42L); + savedComment.setOwnerUsername("alice"); + savedComment.setFilePath("document.pdf"); + savedComment.setAuthorUsername("alice"); + savedComment.setContent(request.getContent()); + savedComment.setCreatedAt(Instant.now()); + + when(commentRepository.save(any(Comment.class))).thenReturn(savedComment); + + CommentDto result = service.addComment(alice, request); + + assertNotNull(result); + assertEquals(42L, result.getId()); + assertEquals("alice", result.getAuthorUsername()); + assertEquals("document.pdf", result.getFilePath()); + assertEquals("Hey @bob check out this file!", result.getContent()); + + verify(notificationService) + .sendNotification( + eq("bob"), + eq("MENTION"), + eq("alice mentioned you in a comment on document.pdf"), + eq("document.pdf"), + eq("alice")); + } + + @Test + void addComment_selfMention_ignored() throws IOException { + Path targetFile = Files.createFile(tempDir.resolve("document.pdf")); + + CreateCommentRequest request = new CreateCommentRequest(); + request.setOwnerUsername("alice"); + request.setFilePath("document.pdf"); + request.setContent("Hey @alice self mention check"); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(alice)); + doNothing().when(folderService).validatePath(eq(alice.getRootFolderPath()), any(Path.class)); + + Comment savedComment = new Comment(); + savedComment.setId(43L); + savedComment.setOwnerUsername("alice"); + savedComment.setFilePath("document.pdf"); + savedComment.setAuthorUsername("alice"); + savedComment.setContent(request.getContent()); + savedComment.setCreatedAt(Instant.now()); + + when(commentRepository.save(any(Comment.class))).thenReturn(savedComment); + + service.addComment(alice, request); + + verify(notificationService, never()).sendNotification(any(), any(), any(), any(), any()); + } + + @Test + void addComment_targetFileNotFound_throwsException() { + CreateCommentRequest request = new CreateCommentRequest(); + request.setOwnerUsername("alice"); + request.setFilePath("missing.pdf"); + request.setContent("Hello missing file"); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(alice)); + + assertThrows(FileNotFoundException.class, () -> service.addComment(alice, request)); + } + + @Test + void addComment_unauthorizedUser_throwsException() { + CreateCommentRequest request = new CreateCommentRequest(); + request.setOwnerUsername("alice"); + request.setFilePath("document.pdf"); + request.setContent("Hi"); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(alice)); + + assertThrows(UnauthorizedAccessException.class, () -> service.addComment(bob, request)); + } + + @Test + void listComments_success() throws IOException { + Path targetFile = Files.createFile(tempDir.resolve("document.pdf")); + + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(alice)); + doNothing().when(folderService).validatePath(eq(alice.getRootFolderPath()), any(Path.class)); + + Comment comment = new Comment(); + comment.setId(101L); + comment.setOwnerUsername("alice"); + comment.setFilePath("document.pdf"); + comment.setAuthorUsername("alice"); + comment.setContent("Initial note"); + comment.setCreatedAt(Instant.now()); + + when(commentRepository.findByOwnerUsernameAndFilePathOrderByCreatedAtAsc( + "alice", "document.pdf")) + .thenReturn(Collections.singletonList(comment)); + + List result = service.listComments(alice, "alice", "document.pdf"); + + assertEquals(1, result.size()); + assertEquals(101L, result.get(0).getId()); + assertEquals("Initial note", result.get(0).getContent()); + } + + @Test + void deleteComment_byAuthor_success() { + Comment comment = new Comment(); + comment.setId(200L); + comment.setOwnerUsername("alice"); + comment.setFilePath("document.pdf"); + comment.setAuthorUsername("bob"); + + when(commentRepository.findById(200L)).thenReturn(Optional.of(comment)); + + service.deleteComment(bob, 200L); + + verify(commentRepository).delete(comment); + } + + @Test + void deleteComment_byOwner_success() { + Comment comment = new Comment(); + comment.setId(200L); + comment.setOwnerUsername("alice"); + comment.setFilePath("document.pdf"); + comment.setAuthorUsername("bob"); + + when(commentRepository.findById(200L)).thenReturn(Optional.of(comment)); + + service.deleteComment(alice, 200L); + + verify(commentRepository).delete(comment); + } + + @Test + void deleteComment_byUnauthorizedUser_throwsException() { + Comment comment = new Comment(); + comment.setId(200L); + comment.setOwnerUsername("alice"); + comment.setFilePath("document.pdf"); + comment.setAuthorUsername("alice"); + + when(commentRepository.findById(200L)).thenReturn(Optional.of(comment)); + + assertThrows(UnauthorizedAccessException.class, () -> service.deleteComment(bob, 200L)); + } +} diff --git a/backend/src/test/java/cloudpage/service/NotificationServiceTest.java b/backend/src/test/java/cloudpage/service/NotificationServiceTest.java new file mode 100644 index 00000000..ccaac887 --- /dev/null +++ b/backend/src/test/java/cloudpage/service/NotificationServiceTest.java @@ -0,0 +1,126 @@ +package cloudpage.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cloudpage.dto.NotificationDto; +import cloudpage.model.Notification; +import cloudpage.repository.NotificationRepository; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock private NotificationRepository notificationRepository; + + private NotificationService service; + + @BeforeEach + void setUp() { + service = new NotificationService(notificationRepository); + } + + @Test + void sendNotification_success() { + service.sendNotification( + "recipient", "MENTION", "Alice mentioned you", "document.pdf", "alice"); + + verify(notificationRepository).save(any(Notification.class)); + } + + @Test + void getNotificationsForUser_all() { + Notification n = new Notification(); + n.setId(1L); + n.setRecipientUsername("recipient"); + n.setType("MENTION"); + n.setMessage("message"); + n.setFilePath("doc.pdf"); + n.setOwnerUsername("owner"); + n.setRead(false); + n.setCreatedAt(Instant.now()); + + when(notificationRepository.findByRecipientUsernameOrderByCreatedAtDesc("recipient")) + .thenReturn(Collections.singletonList(n)); + + List result = service.getNotificationsForUser("recipient", false); + + assertEquals(1, result.size()); + assertEquals("message", result.get(0).getMessage()); + assertFalse(result.get(0).isRead()); + } + + @Test + void getNotificationsForUser_unreadOnly() { + Notification n = new Notification(); + n.setId(2L); + n.setRecipientUsername("recipient"); + n.setType("MENTION"); + n.setMessage("message2"); + n.setFilePath("doc.pdf"); + n.setOwnerUsername("owner"); + n.setRead(false); + n.setCreatedAt(Instant.now()); + + when(notificationRepository.findByRecipientUsernameAndReadOrderByCreatedAtDesc( + "recipient", false)) + .thenReturn(Collections.singletonList(n)); + + List result = service.getNotificationsForUser("recipient", true); + + assertEquals(1, result.size()); + assertEquals("message2", result.get(0).getMessage()); + } + + @Test + void markAsRead_success() { + Notification n = new Notification(); + n.setId(10L); + n.setRecipientUsername("recipient"); + n.setRead(false); + + when(notificationRepository.findByIdAndRecipientUsername(10L, "recipient")) + .thenReturn(Optional.of(n)); + + service.markAsRead("recipient", 10L); + + assertTrue(n.isRead()); + verify(notificationRepository).save(n); + } + + @Test + void markAllAsRead_success() { + Notification n1 = new Notification(); + n1.setId(11L); + n1.setRecipientUsername("recipient"); + n1.setRead(false); + + Notification n2 = new Notification(); + n2.setId(12L); + n2.setRecipientUsername("recipient"); + n2.setRead(false); + + when(notificationRepository.findByRecipientUsernameAndReadOrderByCreatedAtDesc( + "recipient", false)) + .thenReturn(Arrays.asList(n1, n2)); + + service.markAllAsRead("recipient"); + + assertTrue(n1.isRead()); + assertTrue(n2.isRead()); + verify(notificationRepository).saveAll(any()); + } +}