From 8d8076840f194fbf487754a2b7f58b2ead1bb202 Mon Sep 17 00:00:00 2001 From: Enver Eren Date: Tue, 2 Dec 2025 22:34:27 +0300 Subject: [PATCH 001/206] feat(workplace): implement ReviewReaction entity and repository for helpfulCount feature --- .../workplace/model/ReviewReaction.java | 35 +++++++++++++++++++ .../repository/ReviewReactionRepository.java | 15 ++++++++ 2 files changed, 50 insertions(+) create mode 100644 apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/model/ReviewReaction.java create mode 100644 apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/repository/ReviewReactionRepository.java diff --git a/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/model/ReviewReaction.java b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/model/ReviewReaction.java new file mode 100644 index 00000000..7ecf2ff5 --- /dev/null +++ b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/model/ReviewReaction.java @@ -0,0 +1,35 @@ +package org.bounswe.jobboardbackend.workplace.model; + +import jakarta.persistence.*; +import lombok.*; +import org.bounswe.jobboardbackend.auth.model.User; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.Instant; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(uniqueConstraints = { + @UniqueConstraint(columnNames = { "review_id", "user_id" }) +}) +public class ReviewReaction { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "review_id") + private Review review; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @CreationTimestamp + @Column(nullable = false, updatable = false) + private Instant createdAt; +} diff --git a/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/repository/ReviewReactionRepository.java b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/repository/ReviewReactionRepository.java new file mode 100644 index 00000000..5412ce94 --- /dev/null +++ b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/repository/ReviewReactionRepository.java @@ -0,0 +1,15 @@ +package org.bounswe.jobboardbackend.workplace.repository; + +import org.bounswe.jobboardbackend.workplace.model.ReviewReaction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ReviewReactionRepository extends JpaRepository { + boolean existsByReview_IdAndUser_Id(Long reviewId, Long userId); + + Optional findByReview_IdAndUser_Id(Long reviewId, Long userId); + + List findByUser_IdAndReview_IdIn(Long userId, List reviewIds); +} From 519e57465aae4f3fc70dc1a0afdd640d1c7fa413 Mon Sep 17 00:00:00 2001 From: Enver Eren Date: Tue, 2 Dec 2025 22:36:23 +0300 Subject: [PATCH 002/206] feat(workplace): add helpful toggle logic, endpoint and response field --- .../controller/WorkplaceReviewController.java | 42 +- .../workplace/dto/ReviewResponse.java | 1 + .../workplace/service/ReviewService.java | 736 ++++++++++-------- 3 files changed, 423 insertions(+), 356 deletions(-) diff --git a/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewController.java b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewController.java index 5541ab3b..de6a9692 100644 --- a/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewController.java +++ b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewController.java @@ -23,9 +23,11 @@ public class WorkplaceReviewController { private User currentUser() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) throw new AccessDeniedException("Unauthenticated"); + if (auth == null || !auth.isAuthenticated()) + throw new AccessDeniedException("Unauthenticated"); Object principal = auth.getPrincipal(); - if (principal instanceof User u) return u; + if (principal instanceof User u) + return u; if (principal instanceof UserDetails ud) { String key = ud.getUsername(); return userRepository.findByEmail(key).or(() -> userRepository.findByUsername(key)) @@ -39,44 +41,52 @@ private User currentUser() { // === REVIEWS === @PostMapping("/review") public ResponseEntity create(@PathVariable Long workplaceId, - @RequestBody @Valid ReviewCreateRequest req) { + @RequestBody @Valid ReviewCreateRequest req) { var res = reviewService.createReview(workplaceId, req, currentUser()); return ResponseEntity.ok(res); } @GetMapping("/review") public ResponseEntity> list(@PathVariable Long workplaceId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(required = false) String ratingFilter, - @RequestParam(required = false) String sortBy, - @RequestParam(required = false) Boolean hasComment, - @RequestParam(required = false) String policy, - @RequestParam(required = false) Integer policyMin) { - var res = reviewService.listReviews(workplaceId, page, size, ratingFilter, sortBy, hasComment, policy, policyMin); + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String ratingFilter, + @RequestParam(required = false) String sortBy, + @RequestParam(required = false) Boolean hasComment, + @RequestParam(required = false) String policy, + @RequestParam(required = false) Integer policyMin) { + var res = reviewService.listReviews(workplaceId, page, size, ratingFilter, sortBy, hasComment, policy, + policyMin, currentUser()); return ResponseEntity.ok(res); } @GetMapping("/review/{reviewId}") public ResponseEntity getOne(@PathVariable Long workplaceId, - @PathVariable Long reviewId) { - return ResponseEntity.ok(reviewService.getOne(workplaceId, reviewId)); + @PathVariable Long reviewId) { + return ResponseEntity.ok(reviewService.getOne(workplaceId, reviewId, currentUser())); } @PutMapping("/review/{reviewId}") public ResponseEntity update(@PathVariable Long workplaceId, - @PathVariable Long reviewId, - @RequestBody @Valid ReviewUpdateRequest req) { + @PathVariable Long reviewId, + @RequestBody @Valid ReviewUpdateRequest req) { var res = reviewService.updateReview(workplaceId, reviewId, req, currentUser()); return ResponseEntity.ok(res); } @DeleteMapping("/review/{reviewId}") public ResponseEntity delete(@PathVariable Long workplaceId, - @PathVariable Long reviewId) { + @PathVariable Long reviewId) { boolean isAdmin = false; // TODO: implement isAdmin check in milestone 3 reviewService.deleteReview(workplaceId, reviewId, currentUser(), isAdmin); return ResponseEntity.ok(ApiMessage.builder().message("Review deleted").code("REVIEW_DELETED").build()); } + @PostMapping("/review/{reviewId}/helpful") + public ResponseEntity toggleHelpful(@PathVariable Long workplaceId, + @PathVariable Long reviewId) { + var res = reviewService.toggleHelpful(workplaceId, reviewId, currentUser()); + return ResponseEntity.ok(res); + } + } diff --git a/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/dto/ReviewResponse.java b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/dto/ReviewResponse.java index a169c6ab..5974ea35 100644 --- a/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/dto/ReviewResponse.java +++ b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/dto/ReviewResponse.java @@ -23,6 +23,7 @@ public class ReviewResponse { private Double overallRating; private Map ethicalPolicyRatings; private ReplyResponse reply; + private boolean isHelpfulByUser; private Instant createdAt; private Instant updatedAt; } \ No newline at end of file diff --git a/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/service/ReviewService.java b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/service/ReviewService.java index 64b17cee..d3a4c516 100644 --- a/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/service/ReviewService.java +++ b/apps/jobboard-backend/src/main/java/org/bounswe/jobboardbackend/workplace/service/ReviewService.java @@ -24,372 +24,428 @@ @RequiredArgsConstructor public class ReviewService { - private final WorkplaceRepository workplaceRepository; - private final ReviewRepository reviewRepository; - private final ReviewPolicyRatingRepository reviewPolicyRatingRepository; - private final ReviewReplyRepository reviewReplyRepository; - private final EmployerWorkplaceRepository employerWorkplaceRepository; - private final UserRepository userRepository; - private final ProfileRepository profileRepository; - - // === CREATE REVIEW === - @Transactional - public ReviewResponse createReview(Long workplaceId, ReviewCreateRequest req, User currentUser) { - Workplace wp = workplaceRepository.findById(workplaceId) - .orElseThrow(() -> new HandleException( - ErrorCode.WORKPLACE_NOT_FOUND, - "Workplace not found" - )); - - boolean isEmployer = employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, currentUser.getId()); - if (isEmployer) { - throw new HandleException( - ErrorCode.WORKPLACE_UNAUTHORIZED, - "Employers cannot review their own workplace" - ); - } + private final WorkplaceRepository workplaceRepository; + private final ReviewRepository reviewRepository; + private final ReviewPolicyRatingRepository reviewPolicyRatingRepository; + private final ReviewReplyRepository reviewReplyRepository; + private final EmployerWorkplaceRepository employerWorkplaceRepository; + private final UserRepository userRepository; + private final ProfileRepository profileRepository; + private final ReviewReactionRepository reviewReactionRepository; + + // === CREATE REVIEW === + @Transactional + public ReviewResponse createReview(Long workplaceId, ReviewCreateRequest req, User currentUser) { + Workplace wp = workplaceRepository.findById(workplaceId) + .orElseThrow(() -> new HandleException( + ErrorCode.WORKPLACE_NOT_FOUND, + "Workplace not found")); + + boolean isEmployer = employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, + currentUser.getId()); + if (isEmployer) { + throw new HandleException( + ErrorCode.WORKPLACE_UNAUTHORIZED, + "Employers cannot review their own workplace"); + } - boolean alreadyReviewed = reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, currentUser.getId()); - if (alreadyReviewed) { - throw new HandleException( - ErrorCode.REVIEW_ALREADY_EXISTS, - "You have already submitted a review for this workplace." - ); - } + boolean alreadyReviewed = reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, + currentUser.getId()); + if (alreadyReviewed) { + throw new HandleException( + ErrorCode.REVIEW_ALREADY_EXISTS, + "You have already submitted a review for this workplace."); + } - currentUser = userRepository.findById(currentUser.getId()) - .orElseThrow(() -> new HandleException( - ErrorCode.USER_NOT_FOUND, - "User not found" - )); - - Set allowed = wp.getEthicalTags(); - if (allowed == null || allowed.isEmpty()) { - throw new HandleException( - ErrorCode.VALIDATION_ERROR, - "This workplace has no declared ethical tags to rate." - ); - } + currentUser = userRepository.findById(currentUser.getId()) + .orElseThrow(() -> new HandleException( + ErrorCode.USER_NOT_FOUND, + "User not found")); - Map policyMap = req.getEthicalPolicyRatings(); - if (policyMap == null || policyMap.isEmpty()) { - throw new HandleException( - ErrorCode.VALIDATION_ERROR, - "ethicalPolicyRatings must contain at least one policy rating (1..5)" - ); - } + Set allowed = wp.getEthicalTags(); + if (allowed == null || allowed.isEmpty()) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "This workplace has no declared ethical tags to rate."); + } - List> validated = new ArrayList<>(); - for (Map.Entry e : policyMap.entrySet()) { - String key = e.getKey(); - Integer score = e.getValue(); - - if (score == null || score < 1 || score > 5) { - throw new HandleException( - ErrorCode.VALIDATION_ERROR, - "Score for '" + key + "' must be between 1 and 5." - ); - } - - EthicalPolicy policy; - try { - policy = EthicalPolicy.fromLabel(key); - } catch (IllegalArgumentException ex) { - throw new HandleException( - ErrorCode.VALIDATION_ERROR, - "Unknown ethical policy: " + key - ); - } - - if (!allowed.contains(policy)) { - throw new HandleException( - ErrorCode.VALIDATION_ERROR, - "Policy '" + policy.name() + "' is not declared by this workplace." - ); - } - - validated.add(Map.entry(policy, score)); - } + Map policyMap = req.getEthicalPolicyRatings(); + if (policyMap == null || policyMap.isEmpty()) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "ethicalPolicyRatings must contain at least one policy rating (1..5)"); + } - Review review = Review.builder() - .workplace(wp) - .user(currentUser) - .title(req.getTitle()) - .content(req.getContent()) - .anonymous(req.isAnonymous()) - .helpfulCount(0) - .build(); - review = reviewRepository.save(review); - - for (Map.Entry e : validated) { - ReviewPolicyRating rpr = ReviewPolicyRating.builder() - .review(review) - .policy(e.getKey()) - .score(e.getValue()) - .build(); - reviewPolicyRatingRepository.save(rpr); - } + List> validated = new ArrayList<>(); + for (Map.Entry e : policyMap.entrySet()) { + String key = e.getKey(); + Integer score = e.getValue(); + + if (score == null || score < 1 || score > 5) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "Score for '" + key + "' must be between 1 and 5."); + } + + EthicalPolicy policy; + try { + policy = EthicalPolicy.fromLabel(key); + } catch (IllegalArgumentException ex) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "Unknown ethical policy: " + key); + } + + if (!allowed.contains(policy)) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "Policy '" + policy.name() + "' is not declared by this workplace."); + } + + validated.add(Map.entry(policy, score)); + } - double avg = validated.stream().mapToInt(Map.Entry::getValue).average().orElse(0.0); - double overall = Math.round(Math.max(1.0, Math.min(5.0, avg)) * 10.0) / 10.0; - review.setOverallRating(overall); - reviewRepository.save(review); - - wp.setReviewCount(wp.getReviewCount() + 1); - workplaceRepository.save(wp); - - return toResponse(review, true); - } - - // === LIST REVIEWS === - @Transactional(readOnly = true) - public PaginatedResponse listReviews(Long workplaceId, Integer page, Integer size, - String ratingFilter, String sortBy, Boolean hasComment, - String policy, Integer policyMin) { - Workplace wp = workplaceRepository.findById(workplaceId) - .orElseThrow(() -> new HandleException( - ErrorCode.WORKPLACE_NOT_FOUND, - "Workplace not found" - )); - - Pageable pageable = makeSort(page, size, sortBy); - - Page pg; - if (ratingFilter != null && !ratingFilter.isBlank()) { - List values = Arrays.stream(ratingFilter.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .map(Double::parseDouble) - .map(d -> Math.max(1.0, Math.min(5.0, d))) - .sorted() - .toList(); - - if (values.size() == 1) { - pg = reviewRepository.findByWorkplace_IdAndOverallRatingIn(workplaceId, values, pageable); - } else { - Double min = values.getFirst(); - Double max = values.getLast(); - pg = reviewRepository.findByWorkplace_IdAndOverallRatingBetween(workplaceId, min, max, pageable); - } - } else if (Boolean.TRUE.equals(hasComment)) { - pg = reviewRepository.findByWorkplace_IdAndContentIsNotNullAndContentNot(workplaceId, "", pageable); - } else { - pg = reviewRepository.findByWorkplace_Id(workplaceId, pageable); - } + Review review = Review.builder() + .workplace(wp) + .user(currentUser) + .title(req.getTitle()) + .content(req.getContent()) + .anonymous(req.isAnonymous()) + .helpfulCount(0) + .build(); + review = reviewRepository.save(review); + + for (Map.Entry e : validated) { + ReviewPolicyRating rpr = ReviewPolicyRating.builder() + .review(review) + .policy(e.getKey()) + .score(e.getValue()) + .build(); + reviewPolicyRatingRepository.save(rpr); + } + + double avg = validated.stream().mapToInt(Map.Entry::getValue).average().orElse(0.0); + double overall = Math.round(Math.max(1.0, Math.min(5.0, avg)) * 10.0) / 10.0; + review.setOverallRating(overall); + reviewRepository.save(review); + + wp.setReviewCount(wp.getReviewCount() + 1); + workplaceRepository.save(wp); - List content = pg.getContent().stream() - .map(r -> toResponse(r, true)) - .collect(Collectors.toList()); - - return PaginatedResponse.of(content, pg.getNumber(), pg.getSize(), pg.getTotalElements()); - } - - // === GET ONE === - @Transactional(readOnly = true) - public ReviewResponse getOne(Long workplaceId, Long reviewId) { - Review r = reviewRepository.findById(reviewId) - .orElseThrow(() -> new HandleException( - ErrorCode.REVIEW_NOT_FOUND, - "Review not found" - )); - if (!Objects.equals(r.getWorkplace().getId(), workplaceId)) { - throw new HandleException( - ErrorCode.REVIEW_NOT_FOUND, - "Review does not belong to workplace" - ); + return toResponse(review, true, currentUser); } - return toResponse(r, true); - } - - // === UPDATE REVIEW === - @Transactional - public ReviewResponse updateReview(Long workplaceId, Long reviewId, ReviewUpdateRequest req, User currentUser) { - Review r = reviewRepository.findById(reviewId) - .orElseThrow(() -> new HandleException( - ErrorCode.REVIEW_NOT_FOUND, - "Review not found" - )); - if (!Objects.equals(r.getWorkplace().getId(), workplaceId)) { - throw new HandleException( - ErrorCode.REVIEW_NOT_FOUND, - "Review does not belong to workplace" - ); + + // === LIST REVIEWS === + @Transactional(readOnly = true) + public PaginatedResponse listReviews(Long workplaceId, Integer page, Integer size, + String ratingFilter, String sortBy, Boolean hasComment, + String policy, Integer policyMin, User currentUser) { + workplaceRepository.findById(workplaceId) + .orElseThrow(() -> new HandleException( + ErrorCode.WORKPLACE_NOT_FOUND, + "Workplace not found")); + + Pageable pageable = makeSort(page, size, sortBy); + + Page pg; + if (ratingFilter != null && !ratingFilter.isBlank()) { + List values = Arrays.stream(ratingFilter.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Double::parseDouble) + .map(d -> Math.max(1.0, Math.min(5.0, d))) + .sorted() + .toList(); + + if (values.size() == 1) { + pg = reviewRepository.findByWorkplace_IdAndOverallRatingIn(workplaceId, values, + pageable); + } else { + Double min = values.getFirst(); + Double max = values.getLast(); + pg = reviewRepository.findByWorkplace_IdAndOverallRatingBetween(workplaceId, min, max, + pageable); + } + } else if (Boolean.TRUE.equals(hasComment)) { + pg = reviewRepository.findByWorkplace_IdAndContentIsNotNullAndContentNot(workplaceId, "", + pageable); + } else { + pg = reviewRepository.findByWorkplace_Id(workplaceId, pageable); + } + + List reviews = pg.getContent(); + Set likedReviewIds = new HashSet<>(); + if (currentUser != null) { + List reviewIds = reviews.stream().map(Review::getId).toList(); + if (!reviewIds.isEmpty()) { + likedReviewIds = reviewReactionRepository + .findByUser_IdAndReview_IdIn(currentUser.getId(), reviewIds) + .stream() + .map(rr -> rr.getReview().getId()) + .collect(Collectors.toSet()); + } + } + + Set finalLikedReviewIds = likedReviewIds; + List content = reviews.stream() + .map(r -> toResponse(r, true, finalLikedReviewIds.contains(r.getId()))) + .collect(Collectors.toList()); + + return PaginatedResponse.of(content, pg.getNumber(), pg.getSize(), pg.getTotalElements()); } - if (!Objects.equals(r.getUser().getId(), currentUser.getId())) { - throw new HandleException( - ErrorCode.ACCESS_DENIED, - "Only owner can edit the review" - ); + + // === GET ONE === + @Transactional(readOnly = true) + public ReviewResponse getOne(Long workplaceId, Long reviewId, User currentUser) { + Review r = reviewRepository.findById(reviewId) + .orElseThrow(() -> new HandleException( + ErrorCode.REVIEW_NOT_FOUND, + "Review not found")); + if (!Objects.equals(r.getWorkplace().getId(), workplaceId)) { + throw new HandleException( + ErrorCode.REVIEW_NOT_FOUND, + "Review does not belong to workplace"); + } + return toResponse(r, true, currentUser); } - if (req.getTitle() != null) r.setTitle(req.getTitle()); - if (req.getContent() != null) r.setContent(req.getContent()); - if (req.getIsAnonymous() != null) r.setAnonymous(req.getIsAnonymous()); - reviewRepository.save(r); - - if (req.getEthicalPolicyRatings() != null) { - Set allowed = r.getWorkplace().getEthicalTags(); - if (allowed == null || allowed.isEmpty()) { - throw new HandleException( - ErrorCode.VALIDATION_ERROR, - "This workplace has no declared ethical tags to rate." - ); - } - - List existing = reviewPolicyRatingRepository.findByReview_Id(r.getId()); - Map byPolicy = existing.stream() - .collect(Collectors.toMap(ReviewPolicyRating::getPolicy, x -> x)); - - for (Map.Entry e : req.getEthicalPolicyRatings().entrySet()) { - String key = e.getKey(); - Integer score = e.getValue(); - - if (score == null || score < 1 || score > 5) { - throw new HandleException( - ErrorCode.VALIDATION_ERROR, - "Score for '" + key + "' must be between 1 and 5." - ); + // === UPDATE REVIEW === + @Transactional + public ReviewResponse updateReview(Long workplaceId, Long reviewId, ReviewUpdateRequest req, User currentUser) { + Review r = reviewRepository.findById(reviewId) + .orElseThrow(() -> new HandleException( + ErrorCode.REVIEW_NOT_FOUND, + "Review not found")); + if (!Objects.equals(r.getWorkplace().getId(), workplaceId)) { + throw new HandleException( + ErrorCode.REVIEW_NOT_FOUND, + "Review does not belong to workplace"); + } + if (!Objects.equals(r.getUser().getId(), currentUser.getId())) { + throw new HandleException( + ErrorCode.ACCESS_DENIED, + "Only owner can edit the review"); } - EthicalPolicy policy; - try { - policy = EthicalPolicy.fromLabel(key); - } catch (IllegalArgumentException ex) { - throw new HandleException( - ErrorCode.VALIDATION_ERROR, - "Unknown ethical policy: " + key - ); + if (req.getTitle() != null) + r.setTitle(req.getTitle()); + if (req.getContent() != null) + r.setContent(req.getContent()); + if (req.getIsAnonymous() != null) + r.setAnonymous(req.getIsAnonymous()); + reviewRepository.save(r); + + if (req.getEthicalPolicyRatings() != null) { + Set allowed = r.getWorkplace().getEthicalTags(); + if (allowed == null || allowed.isEmpty()) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "This workplace has no declared ethical tags to rate."); + } + + List existing = reviewPolicyRatingRepository.findByReview_Id(r.getId()); + Map byPolicy = existing.stream() + .collect(Collectors.toMap(ReviewPolicyRating::getPolicy, x -> x)); + + for (Map.Entry e : req.getEthicalPolicyRatings().entrySet()) { + String key = e.getKey(); + Integer score = e.getValue(); + + if (score == null || score < 1 || score > 5) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "Score for '" + key + "' must be between 1 and 5."); + } + + EthicalPolicy policy; + try { + policy = EthicalPolicy.fromLabel(key); + } catch (IllegalArgumentException ex) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "Unknown ethical policy: " + key); + } + + if (!allowed.contains(policy)) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "Policy '" + policy.name() + + "' is not declared by this workplace."); + } + + ReviewPolicyRating entity = byPolicy.get(policy); + if (entity != null) { + // UPDATE + entity.setScore(score); + } else { + // INSERT + ReviewPolicyRating created = ReviewPolicyRating.builder() + .review(r) + .policy(policy) + .score(score) + .build(); + reviewPolicyRatingRepository.save(created); + byPolicy.put(policy, created); + } + } + + List current = reviewPolicyRatingRepository.findByReview_Id(r.getId()); + if (!current.isEmpty()) { + double avg = current.stream().mapToInt(ReviewPolicyRating::getScore).average() + .orElse(0.0); + double overall = Math.round(Math.max(1.0, Math.min(5.0, avg)) * 10.0) / 10.0; + r.setOverallRating(overall); + } + reviewRepository.save(r); } - if (!allowed.contains(policy)) { - throw new HandleException( - ErrorCode.VALIDATION_ERROR, - "Policy '" + policy.name() + "' is not declared by this workplace." - ); + return toResponse(r, true, currentUser); + } + + // === TOGGLE HELPFUL === + @Transactional + public ReviewResponse toggleHelpful(Long workplaceId, Long reviewId, User currentUser) { + Review r = reviewRepository.findById(reviewId) + .orElseThrow(() -> new HandleException( + ErrorCode.REVIEW_NOT_FOUND, + "Review not found")); + + if (!Objects.equals(r.getWorkplace().getId(), workplaceId)) { + throw new HandleException( + ErrorCode.REVIEW_NOT_FOUND, + "Review does not belong to workplace"); + } + + if (Objects.equals(r.getUser().getId(), currentUser.getId())) { + throw new HandleException( + ErrorCode.VALIDATION_ERROR, + "You cannot mark your own review as helpful."); } - ReviewPolicyRating entity = byPolicy.get(policy); - if (entity != null) { - // UPDATE - entity.setScore(score); + Optional reaction = reviewReactionRepository.findByReview_IdAndUser_Id(reviewId, + currentUser.getId()); + boolean isHelpful; + + if (reaction.isPresent()) { + // Unlike + reviewReactionRepository.delete(reaction.get()); + r.setHelpfulCount(Math.max(0, r.getHelpfulCount() - 1)); + isHelpful = false; } else { - // INSERT - ReviewPolicyRating created = ReviewPolicyRating.builder() - .review(r) - .policy(policy) - .score(score) - .build(); - reviewPolicyRatingRepository.save(created); - byPolicy.put(policy, created); + // Like + ReviewReaction newReaction = ReviewReaction.builder() + .review(r) + .user(currentUser) + .build(); + reviewReactionRepository.save(newReaction); + r.setHelpfulCount(r.getHelpfulCount() + 1); + isHelpful = true; } - } - List current = reviewPolicyRatingRepository.findByReview_Id(r.getId()); - if (!current.isEmpty()) { - double avg = current.stream().mapToInt(ReviewPolicyRating::getScore).average().orElse(0.0); - double overall = Math.round(Math.max(1.0, Math.min(5.0, avg)) * 10.0) / 10.0; - r.setOverallRating(overall); - } - reviewRepository.save(r); + reviewRepository.save(r); + return toResponse(r, true, isHelpful); } - return toResponse(r, true); - } - - // === DELETE REVIEW === - @Transactional - public void deleteReview(Long workplaceId, Long reviewId, User currentUser, boolean isAdmin) { - Review r = reviewRepository.findById(reviewId) - .orElseThrow(() -> new HandleException( - ErrorCode.REVIEW_NOT_FOUND, - "Review not found" - )); - if (!Objects.equals(r.getWorkplace().getId(), workplaceId)) { - throw new HandleException( - ErrorCode.REVIEW_NOT_FOUND, - "Review does not belong to workplace" - ); + // === DELETE REVIEW === + @Transactional + public void deleteReview(Long workplaceId, Long reviewId, User currentUser, boolean isAdmin) { + Review r = reviewRepository.findById(reviewId) + .orElseThrow(() -> new HandleException( + ErrorCode.REVIEW_NOT_FOUND, + "Review not found")); + if (!Objects.equals(r.getWorkplace().getId(), workplaceId)) { + throw new HandleException( + ErrorCode.REVIEW_NOT_FOUND, + "Review does not belong to workplace"); + } + boolean reviewOwner = Objects.equals(r.getUser().getId(), currentUser.getId()); + if (!(reviewOwner || isAdmin)) { + throw new HandleException( + ErrorCode.ACCESS_DENIED, + "Only review owner or admin can delete the review"); + } + + reviewReplyRepository.findByReview_Id(reviewId).ifPresent(reviewReplyRepository::delete); + reviewPolicyRatingRepository.deleteAll(reviewPolicyRatingRepository.findByReview_Id(reviewId)); + reviewRepository.delete(r); + + Workplace wp = workplaceRepository.findById(workplaceId) + .orElseThrow(() -> new HandleException( + ErrorCode.WORKPLACE_NOT_FOUND, + "Workplace not found")); + long currentCount = wp.getReviewCount(); + wp.setReviewCount(Math.max(0, currentCount - 1)); + workplaceRepository.save(wp); } - boolean reviewOwner = Objects.equals(r.getUser().getId(), currentUser.getId()); - if (!(reviewOwner || isAdmin)) { - throw new HandleException( - ErrorCode.ACCESS_DENIED, - "Only review owner or admin can delete the review" - ); + + // === HELPERS === + private Pageable makeSort(int page, int size, String sortBy) { + if (sortBy == null) { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + } + return switch (sortBy) { + case "ratingAsc" -> PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "overallRating")); + case "ratingDesc" -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "overallRating")); + case "helpfulness" -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "helpfulCount")); + default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + }; } - reviewReplyRepository.findByReview_Id(reviewId).ifPresent(reviewReplyRepository::delete); - reviewPolicyRatingRepository.deleteAll(reviewPolicyRatingRepository.findByReview_Id(reviewId)); - reviewRepository.delete(r); - - Workplace wp = workplaceRepository.findById(workplaceId) - .orElseThrow(() -> new HandleException( - ErrorCode.WORKPLACE_NOT_FOUND, - "Workplace not found" - )); - long currentCount = wp.getReviewCount(); - wp.setReviewCount(Math.max(0, currentCount - 1)); - workplaceRepository.save(wp); - } - - // === HELPERS === - private Pageable makeSort(int page, int size, String sortBy) { - if (sortBy == null) { - return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + private ReviewResponse toResponse(Review r, boolean withExtras, User currentUser) { + boolean isHelpful = false; + if (currentUser != null) { + isHelpful = reviewReactionRepository.existsByReview_IdAndUser_Id(r.getId(), + currentUser.getId()); + } + return toResponse(r, withExtras, isHelpful); } - return switch (sortBy) { - case "ratingAsc" -> PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "overallRating")); - case "ratingDesc" -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "overallRating")); - case "helpfulness" -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "helpfulCount")); - default -> PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - }; - } - - private ReviewResponse toResponse(Review r, boolean withExtras) { - Map policies = Collections.emptyMap(); - ReplyResponse replyDto = null; - if (withExtras) { - policies = reviewPolicyRatingRepository.findByReview_Id(r.getId()).stream() - .collect(Collectors.toMap( - rpr -> rpr.getPolicy().getLabel(), - ReviewPolicyRating::getScore - )); - replyDto = reviewReplyRepository.findByReview_Id(r.getId()) - .map(this::toReplyResponse) - .orElse(null); + + private ReviewResponse toResponse(Review r, boolean withExtras, boolean isHelpfulByUser) { + Map policies = Collections.emptyMap(); + ReplyResponse replyDto = null; + if (withExtras) { + policies = reviewPolicyRatingRepository.findByReview_Id(r.getId()).stream() + .collect(Collectors.toMap( + rpr -> rpr.getPolicy().getLabel(), + ReviewPolicyRating::getScore)); + replyDto = reviewReplyRepository.findByReview_Id(r.getId()) + .map(this::toReplyResponse) + .orElse(null); + } + + String nameSurname = profileRepository.findByUserId(r.getUser().getId()) + .map(p -> p.getFirstName() + " " + p.getLastName()) + .orElse(""); + + return ReviewResponse.builder() + .id(r.getId()) + .workplaceId(r.getWorkplace().getId()) + .userId(r.isAnonymous() ? null : r.getUser().getId()) + .username(r.isAnonymous() ? "" : r.getUser().getUsername()) + .nameSurname(r.isAnonymous() ? "anonymousUser" : nameSurname) + .title(r.getTitle()) + .content(r.getContent()) + .overallRating(r.getOverallRating()) + .anonymous(r.isAnonymous()) + .helpfulCount(r.getHelpfulCount()) + .isHelpfulByUser(isHelpfulByUser) + .ethicalPolicyRatings(policies) + .reply(replyDto) + .createdAt(r.getCreatedAt()) + .updatedAt(r.getUpdatedAt()) + .build(); } - String nameSurname = profileRepository.findByUserId(r.getUser().getId()) - .map(p -> p.getFirstName() + " " + p.getLastName()) - .orElse(""); - - return ReviewResponse.builder() - .id(r.getId()) - .workplaceId(r.getWorkplace().getId()) - .userId(r.isAnonymous() ? null : r.getUser().getId()) - .username(r.isAnonymous() ? "" : r.getUser().getUsername()) - .nameSurname(r.isAnonymous() ? "anonymousUser" : nameSurname) - .title(r.getTitle()) - .content(r.getContent()) - .overallRating(r.getOverallRating()) - .anonymous(r.isAnonymous()) - .helpfulCount(r.getHelpfulCount()) - .ethicalPolicyRatings(policies) - .reply(replyDto) - .createdAt(r.getCreatedAt()) - .updatedAt(r.getUpdatedAt()) - .build(); - } - - private ReplyResponse toReplyResponse(ReviewReply reply) { - return ReplyResponse.builder() - .id(reply.getId()) - .reviewId(reply.getReview().getId()) - .employerUserId(reply.getEmployerUser() != null ? reply.getEmployerUser().getId() : null) - .workplaceName(reply.getReview().getWorkplace().getCompanyName()) - .content(reply.getContent()) - .createdAt(reply.getCreatedAt()) - .updatedAt(reply.getUpdatedAt()) - .build(); - } + private ReplyResponse toReplyResponse(ReviewReply reply) { + return ReplyResponse.builder() + .id(reply.getId()) + .reviewId(reply.getReview().getId()) + .employerUserId(reply.getEmployerUser() != null ? reply.getEmployerUser().getId() + : null) + .workplaceName(reply.getReview().getWorkplace().getCompanyName()) + .content(reply.getContent()) + .createdAt(reply.getCreatedAt()) + .updatedAt(reply.getUpdatedAt()) + .build(); + } } \ No newline at end of file From 6fe9cb0675604d987585712d46ad02799055ea35 Mon Sep 17 00:00:00 2001 From: Enver Eren Date: Tue, 2 Dec 2025 22:38:24 +0300 Subject: [PATCH 003/206] test(workplace): add unit and integration tests for helpfulCount feature --- .../WorkplaceReviewControllerTest.java | 821 ++++---- .../workplace/service/ReviewServiceTest.java | 1763 +++++++++-------- 2 files changed, 1339 insertions(+), 1245 deletions(-) diff --git a/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewControllerTest.java b/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewControllerTest.java index 8a7dc4cf..3c8c2823 100644 --- a/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewControllerTest.java +++ b/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewControllerTest.java @@ -40,399 +40,430 @@ @WebMvcTest(controllers = WorkplaceReviewController.class) class WorkplaceReviewControllerTest { - @Autowired - private MockMvc mockMvc; - - @MockitoBean - private ReviewService reviewService; - - @MockitoBean - private UserRepository userRepository; - - @Autowired - private ObjectMapper objectMapper; - - private User userEntity(Long id) { - return User.builder() - .id(id) - .username("user" + id) - .email("user" + id + "@test.com") - .password("password-" + id) - .role(Role.ROLE_JOBSEEKER) - .emailVerified(true) - .build(); - } - - private final User USER_1 = userEntity(1L); - private final User USER_2 = userEntity(2L); - - // ======================================================================== - // CREATE REVIEW (POST /api/workplace/{workplaceId}/review) - // ======================================================================== - - @Test - void create_whenPrincipalIsDomainUser_callsServiceWithSameUser() throws Exception { - Long workplaceId = 10L; - - Authentication auth = new UsernamePasswordAuthenticationToken( - USER_1, - null, - Collections.emptyList() - ); - - ReviewCreateRequest req = new ReviewCreateRequest(); - String body = objectMapper.writeValueAsString(req); - - ReviewResponse response = new ReviewResponse(); - when(reviewService.createReview(eq(workplaceId), any(ReviewCreateRequest.class), eq(USER_1))) - .thenReturn(response); - - mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) - .with(authentication(auth)) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isOk()); - - ArgumentCaptor workplaceCaptor = ArgumentCaptor.forClass(Long.class); - ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(ReviewCreateRequest.class); - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); - - verify(reviewService).createReview(workplaceCaptor.capture(), reqCaptor.capture(), userCaptor.capture()); - assertThat(workplaceCaptor.getValue()).isEqualTo(workplaceId); - assertThat(userCaptor.getValue().getId()).isEqualTo(USER_1.getId()); - } - - @Test - void create_whenUserDetailsPrincipal_resolvesUserFromEmail_andCallsService() throws Exception { - Long workplaceId = 11L; - User domainUser = USER_2; - String principalKey = domainUser.getEmail(); - - when(userRepository.findByEmail(principalKey)).thenReturn(Optional.of(domainUser)); - - ReviewCreateRequest req = new ReviewCreateRequest(); - String body = objectMapper.writeValueAsString(req); - - when(reviewService.createReview(eq(workplaceId), any(ReviewCreateRequest.class), eq(domainUser))) - .thenReturn(new ReviewResponse()); - - mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) - .with(user(principalKey)) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isOk()); - - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); - verify(reviewService).createReview(eq(workplaceId), any(ReviewCreateRequest.class), userCaptor.capture()); - assertThat(userCaptor.getValue().getId()).isEqualTo(domainUser.getId()); - } - - @Test - void create_whenUserDetailsPrincipalUserNotFound_returnsForbidden_andDoesNotCallService() throws Exception { - Long workplaceId = 12L; - String principalKey = "unknown@test.com"; - - when(userRepository.findByEmail(principalKey)).thenReturn(Optional.empty()); - when(userRepository.findByUsername(principalKey)).thenReturn(Optional.empty()); - - ReviewCreateRequest req = new ReviewCreateRequest(); - String body = objectMapper.writeValueAsString(req); - - mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) - .with(user(principalKey)) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isForbidden()); - - verify(reviewService, never()).createReview(anyLong(), any(), any()); - } - - @Test - void create_whenPrincipalIsName_resolvesUserFromName_andCallsService() throws Exception { - Long workplaceId = 13L; - User domainUser = USER_1; - String name = domainUser.getEmail(); - - when(userRepository.findByEmail(name)).thenReturn(Optional.of(domainUser)); - - Authentication auth = new UsernamePasswordAuthenticationToken( - name, - "pwd", - Collections.emptyList() - ); - - ReviewCreateRequest req = new ReviewCreateRequest(); - String body = objectMapper.writeValueAsString(req); - - when(reviewService.createReview(eq(workplaceId), any(ReviewCreateRequest.class), eq(domainUser))) - .thenReturn(new ReviewResponse()); - - mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) - .with(authentication(auth)) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isOk()); - - ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); - verify(reviewService).createReview(eq(workplaceId), any(ReviewCreateRequest.class), userCaptor.capture()); - assertThat(userCaptor.getValue().getId()).isEqualTo(domainUser.getId()); - } - - @Test - void create_whenUnauthenticated_returnsUnauthorized_andDoesNotCallService() throws Exception { - Long workplaceId = 14L; - - ReviewCreateRequest req = new ReviewCreateRequest(); - String body = objectMapper.writeValueAsString(req); - - mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isUnauthorized()); - - verify(reviewService, never()).createReview(anyLong(), any(), any()); - } - - // ======================================================================== - // LIST REVIEWS (GET /api/workplace/{workplaceId}/review) - // ======================================================================== - - @Test - void list_whenCalledWithAllParams_delegatesToService() throws Exception { - Long workplaceId = 20L; - int page = 1; - int size = 5; - String ratingFilter = "3.5,4.2"; - String sortBy = "rating"; - Boolean hasComment = true; - String policy = "ENVIRONMENT"; - Integer policyMin = 2; - - PaginatedResponse response = - PaginatedResponse.of(Collections.emptyList(), page, size, 0); - when(reviewService.listReviews( - anyLong(), anyInt(), anyInt(), any(), any(), any(), any(), any()) - ).thenReturn(response); - - mockMvc.perform(get("/api/workplace/{workplaceId}/review", workplaceId) - .with(user("user1@test.com")) - .param("page", String.valueOf(page)) - .param("size", String.valueOf(size)) - .param("ratingFilter", ratingFilter) - .param("sortBy", sortBy) - .param("hasComment", String.valueOf(hasComment)) - .param("policy", policy) - .param("policyMin", String.valueOf(policyMin))) - .andExpect(status().isOk()); - - ArgumentCaptor wpCaptor = ArgumentCaptor.forClass(Long.class); - ArgumentCaptor pageCaptor = ArgumentCaptor.forClass(Integer.class); - ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Integer.class); - ArgumentCaptor ratingCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor sortCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor hasCommentCaptor = ArgumentCaptor.forClass(Boolean.class); - ArgumentCaptor policyCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor policyMinCaptor = ArgumentCaptor.forClass(Integer.class); - - verify(reviewService).listReviews( - wpCaptor.capture(), - pageCaptor.capture(), - sizeCaptor.capture(), - ratingCaptor.capture(), - sortCaptor.capture(), - hasCommentCaptor.capture(), - policyCaptor.capture(), - policyMinCaptor.capture() - ); - - assertThat(wpCaptor.getValue()).isEqualTo(workplaceId); - assertThat(pageCaptor.getValue()).isEqualTo(page); - assertThat(sizeCaptor.getValue()).isEqualTo(size); - assertThat(ratingCaptor.getValue()).isEqualTo(ratingFilter); - assertThat(sortCaptor.getValue()).isEqualTo(sortBy); - assertThat(hasCommentCaptor.getValue()).isEqualTo(hasComment); - assertThat(policyCaptor.getValue()).isEqualTo(policy); - assertThat(policyMinCaptor.getValue()).isEqualTo(policyMin); - } - - @Test - void list_whenCalledWithoutParams_usesDefaults() throws Exception { - Long workplaceId = 21L; - - PaginatedResponse response = - PaginatedResponse.of(Collections.emptyList(), 0, 10, 0); - when(reviewService.listReviews( - anyLong(), anyInt(), anyInt(), any(), any(), any(), any(), any()) - ).thenReturn(response); - - mockMvc.perform(get("/api/workplace/{workplaceId}/review", workplaceId) - .with(user("user1@test.com"))) - .andExpect(status().isOk()); - - ArgumentCaptor pageCaptor = ArgumentCaptor.forClass(Integer.class); - ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Integer.class); - - verify(reviewService).listReviews( - anyLong(), - pageCaptor.capture(), - sizeCaptor.capture(), - any(), - any(), - any(), - any(), - any() - ); - - assertThat(pageCaptor.getValue()).isEqualTo(0); - assertThat(sizeCaptor.getValue()).isEqualTo(10); - } - - // ======================================================================== - // GET ONE REVIEW (GET /api/workplace/{workplaceId}/review/{reviewId}) - // ======================================================================== - - @Test - void getOne_whenCalled_delegatesToService() throws Exception { - Long workplaceId = 30L; - Long reviewId = 300L; - - ReviewResponse res = new ReviewResponse(); - when(reviewService.getOne(workplaceId, reviewId)).thenReturn(res); - - mockMvc.perform(get("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) - .with(user("user1@test.com"))) - .andExpect(status().isOk()); - - verify(reviewService).getOne(workplaceId, reviewId); - } - - // ======================================================================== - // UPDATE REVIEW (PUT /api/workplace/{workplaceId}/review/{reviewId}) - // ======================================================================== - - @Test - void update_whenPrincipalIsDomainUser_callsServiceWithSameUser() throws Exception { - Long workplaceId = 40L; - Long reviewId = 400L; - - Authentication auth = new UsernamePasswordAuthenticationToken( - USER_1, - null, - Collections.emptyList() - ); - - ReviewUpdateRequest req = new ReviewUpdateRequest(); - String body = objectMapper.writeValueAsString(req); - - ReviewResponse response = new ReviewResponse(); - when(reviewService.updateReview(eq(workplaceId), eq(reviewId), any(ReviewUpdateRequest.class), eq(USER_1))) - .thenReturn(response); - - mockMvc.perform(put("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) - .with(authentication(auth)) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isOk()); - - verify(reviewService).updateReview(eq(workplaceId), eq(reviewId), any(ReviewUpdateRequest.class), eq(USER_1)); - } - - @Test - void update_whenUserDetailsPrincipalUserNotFound_returnsForbidden_andDoesNotCallService() throws Exception { - Long workplaceId = 41L; - Long reviewId = 401L; - String principalKey = "unknown@test.com"; - - when(userRepository.findByEmail(principalKey)).thenReturn(Optional.empty()); - when(userRepository.findByUsername(principalKey)).thenReturn(Optional.empty()); - - ReviewUpdateRequest req = new ReviewUpdateRequest(); - String body = objectMapper.writeValueAsString(req); - - mockMvc.perform(put("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) - .with(user(principalKey)) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isForbidden()); - - verify(reviewService, never()).updateReview(anyLong(), anyLong(), any(), any()); - } - - @Test - void update_whenUnauthenticated_returnsUnauthorized_andDoesNotCallService() throws Exception { - Long workplaceId = 42L; - Long reviewId = 402L; - - ReviewUpdateRequest req = new ReviewUpdateRequest(); - String body = objectMapper.writeValueAsString(req); - - mockMvc.perform(put("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isUnauthorized()); - - verify(reviewService, never()).updateReview(anyLong(), anyLong(), any(), any()); - } - - // ======================================================================== - // DELETE REVIEW (DELETE /api/workplace/{workplaceId}/review/{reviewId}) - // ======================================================================== - - @Test - void delete_whenPrincipalIsDomainUser_callsServiceWithIsAdminFalse_andReturnsApiMessage() throws Exception { - Long workplaceId = 50L; - Long reviewId = 500L; - - Authentication auth = new UsernamePasswordAuthenticationToken( - USER_1, - null, - Collections.emptyList() - ); - - mockMvc.perform(delete("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) - .with(authentication(auth)) - .with(csrf())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Review deleted")) - .andExpect(jsonPath("$.code").value("REVIEW_DELETED")); - - verify(reviewService).deleteReview(workplaceId, reviewId, USER_1, false); - } - - @Test - void delete_whenUserDetailsPrincipalUserNotFound_returnsForbidden_andDoesNotCallService() throws Exception { - Long workplaceId = 51L; - Long reviewId = 501L; - String principalKey = "unknown@test.com"; - - when(userRepository.findByEmail(principalKey)).thenReturn(Optional.empty()); - when(userRepository.findByUsername(principalKey)).thenReturn(Optional.empty()); - - mockMvc.perform(delete("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) - .with(user(principalKey)) - .with(csrf())) - .andExpect(status().isForbidden()); - - verify(reviewService, never()).deleteReview(anyLong(), anyLong(), any(), anyBoolean()); - } - - @Test - void delete_whenUnauthenticated_returnsUnauthorized_andDoesNotCallService() throws Exception { - Long workplaceId = 52L; - Long reviewId = 502L; - - mockMvc.perform(delete("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) - .with(csrf())) - .andExpect(status().isUnauthorized()); - - verify(reviewService, never()).deleteReview(anyLong(), anyLong(), any(), anyBoolean()); - } + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ReviewService reviewService; + + @MockitoBean + private UserRepository userRepository; + + @Autowired + private ObjectMapper objectMapper; + + private User userEntity(Long id) { + return User.builder() + .id(id) + .username("user" + id) + .email("user" + id + "@test.com") + .password("password-" + id) + .role(Role.ROLE_JOBSEEKER) + .emailVerified(true) + .build(); + } + + private final User USER_1 = userEntity(1L); + private final User USER_2 = userEntity(2L); + + // ======================================================================== + // CREATE REVIEW (POST /api/workplace/{workplaceId}/review) + // ======================================================================== + + @Test + void create_whenPrincipalIsDomainUser_callsServiceWithSameUser() throws Exception { + Long workplaceId = 10L; + + Authentication auth = new UsernamePasswordAuthenticationToken( + USER_1, + null, + Collections.emptyList()); + + ReviewCreateRequest req = new ReviewCreateRequest(); + String body = objectMapper.writeValueAsString(req); + + ReviewResponse response = new ReviewResponse(); + when(reviewService.createReview(eq(workplaceId), any(ReviewCreateRequest.class), eq(USER_1))) + .thenReturn(response); + + mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()); + + ArgumentCaptor workplaceCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(ReviewCreateRequest.class); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + + verify(reviewService).createReview(workplaceCaptor.capture(), reqCaptor.capture(), + userCaptor.capture()); + assertThat(workplaceCaptor.getValue()).isEqualTo(workplaceId); + assertThat(userCaptor.getValue().getId()).isEqualTo(USER_1.getId()); + } + + @Test + void create_whenUserDetailsPrincipal_resolvesUserFromEmail_andCallsService() throws Exception { + Long workplaceId = 11L; + User domainUser = USER_2; + String principalKey = domainUser.getEmail(); + + when(userRepository.findByEmail(principalKey)).thenReturn(Optional.of(domainUser)); + + ReviewCreateRequest req = new ReviewCreateRequest(); + String body = objectMapper.writeValueAsString(req); + + when(reviewService.createReview(eq(workplaceId), any(ReviewCreateRequest.class), eq(domainUser))) + .thenReturn(new ReviewResponse()); + + mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) + .with(user(principalKey)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(reviewService).createReview(eq(workplaceId), any(ReviewCreateRequest.class), + userCaptor.capture()); + assertThat(userCaptor.getValue().getId()).isEqualTo(domainUser.getId()); + } + + @Test + void create_whenUserDetailsPrincipalUserNotFound_returnsForbidden_andDoesNotCallService() throws Exception { + Long workplaceId = 12L; + String principalKey = "unknown@test.com"; + + when(userRepository.findByEmail(principalKey)).thenReturn(Optional.empty()); + when(userRepository.findByUsername(principalKey)).thenReturn(Optional.empty()); + + ReviewCreateRequest req = new ReviewCreateRequest(); + String body = objectMapper.writeValueAsString(req); + + mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) + .with(user(principalKey)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isForbidden()); + + verify(reviewService, never()).createReview(anyLong(), any(), any()); + } + + @Test + void create_whenPrincipalIsName_resolvesUserFromName_andCallsService() throws Exception { + Long workplaceId = 13L; + User domainUser = USER_1; + String name = domainUser.getEmail(); + + when(userRepository.findByEmail(name)).thenReturn(Optional.of(domainUser)); + + Authentication auth = new UsernamePasswordAuthenticationToken( + name, + "pwd", + Collections.emptyList()); + + ReviewCreateRequest req = new ReviewCreateRequest(); + String body = objectMapper.writeValueAsString(req); + + when(reviewService.createReview(eq(workplaceId), any(ReviewCreateRequest.class), eq(domainUser))) + .thenReturn(new ReviewResponse()); + + mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(reviewService).createReview(eq(workplaceId), any(ReviewCreateRequest.class), + userCaptor.capture()); + assertThat(userCaptor.getValue().getId()).isEqualTo(domainUser.getId()); + } + + @Test + void create_whenUnauthenticated_returnsUnauthorized_andDoesNotCallService() throws Exception { + Long workplaceId = 14L; + + ReviewCreateRequest req = new ReviewCreateRequest(); + String body = objectMapper.writeValueAsString(req); + + mockMvc.perform(post("/api/workplace/{workplaceId}/review", workplaceId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isUnauthorized()); + + verify(reviewService, never()).createReview(anyLong(), any(), any()); + } + + // ======================================================================== + // LIST REVIEWS (GET /api/workplace/{workplaceId}/review) + // ======================================================================== + + @Test + void list_whenCalledWithAllParams_delegatesToService() throws Exception { + Long workplaceId = 20L; + int page = 1; + int size = 5; + String ratingFilter = "3.5,4.2"; + String sortBy = "rating"; + Boolean hasComment = true; + String policy = "ENVIRONMENT"; + Integer policyMin = 2; + + PaginatedResponse response = PaginatedResponse.of(Collections.emptyList(), page, size, + 0); + when(reviewService.listReviews( + anyLong(), anyInt(), anyInt(), any(), any(), any(), any(), any(), any())) + .thenReturn(response); + + mockMvc.perform(get("/api/workplace/{workplaceId}/review", workplaceId) + .with(user("user1@test.com")) + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + .param("ratingFilter", ratingFilter) + .param("sortBy", sortBy) + .param("hasComment", String.valueOf(hasComment)) + .param("policy", policy) + .param("policyMin", String.valueOf(policyMin))) + .andExpect(status().isOk()); + + ArgumentCaptor wpCaptor = ArgumentCaptor.forClass(Long.class); + ArgumentCaptor pageCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor ratingCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor sortCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor hasCommentCaptor = ArgumentCaptor.forClass(Boolean.class); + ArgumentCaptor policyCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor policyMinCaptor = ArgumentCaptor.forClass(Integer.class); + + verify(reviewService).listReviews( + wpCaptor.capture(), + pageCaptor.capture(), + sizeCaptor.capture(), + ratingCaptor.capture(), + sortCaptor.capture(), + hasCommentCaptor.capture(), + policyCaptor.capture(), + policyMinCaptor.capture(), + any()); + + assertThat(wpCaptor.getValue()).isEqualTo(workplaceId); + assertThat(pageCaptor.getValue()).isEqualTo(page); + assertThat(sizeCaptor.getValue()).isEqualTo(size); + assertThat(ratingCaptor.getValue()).isEqualTo(ratingFilter); + assertThat(sortCaptor.getValue()).isEqualTo(sortBy); + assertThat(hasCommentCaptor.getValue()).isEqualTo(hasComment); + assertThat(policyCaptor.getValue()).isEqualTo(policy); + assertThat(policyMinCaptor.getValue()).isEqualTo(policyMin); + } + + @Test + void list_whenCalledWithoutParams_usesDefaults() throws Exception { + Long workplaceId = 21L; + + PaginatedResponse response = PaginatedResponse.of(Collections.emptyList(), 0, 10, 0); + when(reviewService.listReviews( + anyLong(), anyInt(), anyInt(), any(), any(), any(), any(), any(), any())) + .thenReturn(response); + + mockMvc.perform(get("/api/workplace/{workplaceId}/review", workplaceId) + .with(user("user1@test.com"))) + .andExpect(status().isOk()); + + ArgumentCaptor pageCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Integer.class); + + verify(reviewService).listReviews( + anyLong(), + pageCaptor.capture(), + sizeCaptor.capture(), + any(), + any(), + any(), + any(), + any(), + any()); + + assertThat(pageCaptor.getValue()).isEqualTo(0); + assertThat(sizeCaptor.getValue()).isEqualTo(10); + } + + // ======================================================================== + // GET ONE REVIEW (GET /api/workplace/{workplaceId}/review/{reviewId}) + // ======================================================================== + + @Test + void getOne_whenCalled_delegatesToService() throws Exception { + Long workplaceId = 30L; + Long reviewId = 300L; + + ReviewResponse res = new ReviewResponse(); + when(reviewService.getOne(eq(workplaceId), eq(reviewId), any())).thenReturn(res); + + mockMvc.perform(get("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) + .with(user("user1@test.com"))) + .andExpect(status().isOk()); + + verify(reviewService).getOne(eq(workplaceId), eq(reviewId), any()); + } + + // ======================================================================== + // UPDATE REVIEW (PUT /api/workplace/{workplaceId}/review/{reviewId}) + // ======================================================================== + + @Test + void update_whenPrincipalIsDomainUser_callsServiceWithSameUser() throws Exception { + Long workplaceId = 40L; + Long reviewId = 400L; + + Authentication auth = new UsernamePasswordAuthenticationToken( + USER_1, + null, + Collections.emptyList()); + + ReviewUpdateRequest req = new ReviewUpdateRequest(); + String body = objectMapper.writeValueAsString(req); + + ReviewResponse response = new ReviewResponse(); + when(reviewService.updateReview(eq(workplaceId), eq(reviewId), any(ReviewUpdateRequest.class), + eq(USER_1))) + .thenReturn(response); + + mockMvc.perform(put("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) + .with(authentication(auth)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()); + + verify(reviewService).updateReview(eq(workplaceId), eq(reviewId), any(ReviewUpdateRequest.class), + eq(USER_1)); + } + + @Test + void update_whenUserDetailsPrincipalUserNotFound_returnsForbidden_andDoesNotCallService() throws Exception { + Long workplaceId = 41L; + Long reviewId = 401L; + String principalKey = "unknown@test.com"; + + when(userRepository.findByEmail(principalKey)).thenReturn(Optional.empty()); + when(userRepository.findByUsername(principalKey)).thenReturn(Optional.empty()); + + ReviewUpdateRequest req = new ReviewUpdateRequest(); + String body = objectMapper.writeValueAsString(req); + + mockMvc.perform(put("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) + .with(user(principalKey)) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isForbidden()); + + verify(reviewService, never()).updateReview(anyLong(), anyLong(), any(), any()); + } + + @Test + void update_whenUnauthenticated_returnsUnauthorized_andDoesNotCallService() throws Exception { + Long workplaceId = 42L; + Long reviewId = 402L; + + ReviewUpdateRequest req = new ReviewUpdateRequest(); + String body = objectMapper.writeValueAsString(req); + + mockMvc.perform(put("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isUnauthorized()); + + verify(reviewService, never()).updateReview(anyLong(), anyLong(), any(), any()); + } + + // ======================================================================== + // DELETE REVIEW (DELETE /api/workplace/{workplaceId}/review/{reviewId}) + // ======================================================================== + + @Test + void delete_whenPrincipalIsDomainUser_callsServiceWithIsAdminFalse_andReturnsApiMessage() throws Exception { + Long workplaceId = 50L; + Long reviewId = 500L; + + Authentication auth = new UsernamePasswordAuthenticationToken( + USER_1, + null, + Collections.emptyList()); + + mockMvc.perform(delete("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Review deleted")) + .andExpect(jsonPath("$.code").value("REVIEW_DELETED")); + + verify(reviewService).deleteReview(workplaceId, reviewId, USER_1, false); + } + + @Test + void delete_whenUserDetailsPrincipalUserNotFound_returnsForbidden_andDoesNotCallService() throws Exception { + Long workplaceId = 51L; + Long reviewId = 501L; + String principalKey = "unknown@test.com"; + + when(userRepository.findByEmail(principalKey)).thenReturn(Optional.empty()); + when(userRepository.findByUsername(principalKey)).thenReturn(Optional.empty()); + + mockMvc.perform(delete("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) + .with(user(principalKey)) + .with(csrf())) + .andExpect(status().isForbidden()); + + verify(reviewService, never()).deleteReview(anyLong(), anyLong(), any(), anyBoolean()); + } + + @Test + void delete_whenUnauthenticated_returnsUnauthorized_andDoesNotCallService() throws Exception { + Long workplaceId = 52L; + Long reviewId = 502L; + + mockMvc.perform(delete("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) + .with(csrf())) + .andExpect(status().isUnauthorized()); + + verify(reviewService, never()).deleteReview(anyLong(), anyLong(), any(), anyBoolean()); + } + + // ======================================================================== + // TOGGLE HELPFUL (POST /api/workplace/{workplaceId}/review/{reviewId}/helpful) + // ======================================================================== + + @Test + void toggleHelpful_whenAuthenticated_callsService() throws Exception { + Long workplaceId = 60L; + Long reviewId = 600L; + + Authentication auth = new UsernamePasswordAuthenticationToken( + USER_1, + null, + Collections.emptyList()); + + ReviewResponse response = new ReviewResponse(); + response.setHelpfulCount(1); + response.setHelpfulByUser(true); + + when(reviewService.toggleHelpful(eq(workplaceId), eq(reviewId), eq(USER_1))) + .thenReturn(response); + + mockMvc.perform(post("/api/workplace/{workplaceId}/review/{reviewId}/helpful", workplaceId, reviewId) + .with(authentication(auth)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.helpfulCount").value(1)) + .andExpect(jsonPath("$.helpfulByUser").value(true)); + + verify(reviewService).toggleHelpful(workplaceId, reviewId, USER_1); + } } diff --git a/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/service/ReviewServiceTest.java b/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/service/ReviewServiceTest.java index 5acbbc3e..ce60f1b8 100644 --- a/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/service/ReviewServiceTest.java +++ b/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/service/ReviewServiceTest.java @@ -32,863 +32,926 @@ @ExtendWith(MockitoExtension.class) class ReviewServiceTest { - @Mock - private WorkplaceRepository workplaceRepository; - - @Mock - private ReviewRepository reviewRepository; - - @Mock - private ReviewPolicyRatingRepository reviewPolicyRatingRepository; - - @Mock - private ReviewReplyRepository reviewReplyRepository; - - @Mock - private EmployerWorkplaceRepository employerWorkplaceRepository; - - @Mock - private UserRepository userRepository; - - @Mock - private ProfileRepository profileRepository; - - @InjectMocks - private ReviewService reviewService; - - // --------- helpers --------- - - private Workplace sampleWorkplace(Long id) { - Workplace wp = new Workplace(); - wp.setId(id); - wp.setCompanyName("Acme Inc"); - wp.setEthicalTags(Set.of(EthicalPolicy.SALARY_TRANSPARENCY)); - wp.setReviewCount(0L); - return wp; - } - - private User sampleUser(Long id) { - User u = new User(); - u.setId(id); - u.setUsername("user" + id); - u.setEmail("user" + id + "@test.com"); - return u; - } - - private Profile sampleProfile(Long userId) { - Profile p = new Profile(); - p.setId(userId); - p.setFirstName("John"); - p.setLastName("Doe"); - return p; - } - - private Review sampleReview(Long id, Workplace wp, User user) { - Review r = Review.builder() - .id(id) - .workplace(wp) - .user(user) - .title("Good place") - .content("Nice culture") - .overallRating(4.0) - .anonymous(false) - .helpfulCount(0) - .build(); - r.setCreatedAt(Instant.now()); - r.setUpdatedAt(Instant.now()); - return r; - } - - // ========== CREATE REVIEW ========== - - @Test - void createReview_whenValidRequest_createsReviewAndReturnsResponse() { - Long workplaceId = 1L; - Long userId = 10L; - - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(userId); - Profile profile = sampleProfile(userId); - - ReviewCreateRequest req = new ReviewCreateRequest(); - req.setTitle("Great workplace"); - req.setContent("Very ethical"); - Map policyRatings = new HashMap<>(); - policyRatings.put(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4); - req.setEthicalPolicyRatings(policyRatings); - req.setAnonymous(false); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - when(reviewRepository.save(any(Review.class))).thenAnswer(invocation -> { - Review r = invocation.getArgument(0); - if (r.getId() == null) { - r.setId(100L); - } - if (r.getCreatedAt() == null) r.setCreatedAt(Instant.now()); - if (r.getUpdatedAt() == null) r.setUpdatedAt(Instant.now()); - return r; - }); - - when(reviewPolicyRatingRepository.findByReview_Id(anyLong())) - .thenReturn(List.of( - ReviewPolicyRating.builder() + @Mock + private WorkplaceRepository workplaceRepository; + + @Mock + private ReviewRepository reviewRepository; + + @Mock + private ReviewPolicyRatingRepository reviewPolicyRatingRepository; + + @Mock + private ReviewReplyRepository reviewReplyRepository; + + @Mock + private EmployerWorkplaceRepository employerWorkplaceRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ProfileRepository profileRepository; + + @Mock + private ReviewReactionRepository reviewReactionRepository; + + @InjectMocks + private ReviewService reviewService; + + // --------- helpers --------- + + private Workplace sampleWorkplace(Long id) { + Workplace wp = new Workplace(); + wp.setId(id); + wp.setCompanyName("Acme Inc"); + wp.setEthicalTags(Set.of(EthicalPolicy.SALARY_TRANSPARENCY)); + wp.setReviewCount(0L); + return wp; + } + + private User sampleUser(Long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + u.setEmail("user" + id + "@test.com"); + return u; + } + + private Profile sampleProfile(Long userId) { + Profile p = new Profile(); + p.setId(userId); + p.setFirstName("John"); + p.setLastName("Doe"); + return p; + } + + private Review sampleReview(Long id, Workplace wp, User user) { + Review r = Review.builder() + .id(id) + .workplace(wp) + .user(user) + .title("Good place") + .content("Nice culture") + .overallRating(4.0) + .anonymous(false) + .helpfulCount(0) + .build(); + r.setCreatedAt(Instant.now()); + r.setUpdatedAt(Instant.now()); + return r; + } + + // ========== CREATE REVIEW ========== + + @Test + void createReview_whenValidRequest_createsReviewAndReturnsResponse() { + Long workplaceId = 1L; + Long userId = 10L; + + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + Profile profile = sampleProfile(userId); + + ReviewCreateRequest req = new ReviewCreateRequest(); + req.setTitle("Great workplace"); + req.setContent("Very ethical"); + Map policyRatings = new HashMap<>(); + policyRatings.put(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4); + req.setEthicalPolicyRatings(policyRatings); + req.setAnonymous(false); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + when(reviewRepository.save(any(Review.class))).thenAnswer(invocation -> { + Review r = invocation.getArgument(0); + if (r.getId() == null) { + r.setId(100L); + } + if (r.getCreatedAt() == null) + r.setCreatedAt(Instant.now()); + if (r.getUpdatedAt() == null) + r.setUpdatedAt(Instant.now()); + return r; + }); + + when(reviewPolicyRatingRepository.findByReview_Id(anyLong())) + .thenReturn(List.of( + ReviewPolicyRating.builder() + .id(1L) + .review(null) + .policy(EthicalPolicy.SALARY_TRANSPARENCY) + .score(4) + .build())); + when(reviewReplyRepository.findByReview_Id(anyLong())) + .thenReturn(Optional.empty()); + when(profileRepository.findByUserId(userId)) + .thenReturn(Optional.of(profile)); + + ReviewResponse res = reviewService.createReview(workplaceId, req, user); + + assertThat(res).isNotNull(); + assertThat(res.getWorkplaceId()).isEqualTo(workplaceId); + assertThat(res.getTitle()).isEqualTo("Great workplace"); + assertThat(res.getOverallRating()).isEqualTo(4.0); + assertThat(res.getEthicalPolicyRatings()) + .containsEntry(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4); + + verify(workplaceRepository).save(wp); + verify(reviewPolicyRatingRepository, times(1)).findByReview_Id(anyLong()); + } + + @Test + void createReview_whenEmployerOfWorkplace_throwsHandleException() { + Long workplaceId = 1L; + Long userId = 10L; + + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + + ReviewCreateRequest req = new ReviewCreateRequest(); + req.setEthicalPolicyRatings( + Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4)); + req.setTitle("x"); + req.setContent("y"); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(true); + + assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.WORKPLACE_UNAUTHORIZED); + } + + @Test + void createReview_whenAlreadyReviewed_throwsHandleException() { + Long workplaceId = 1L; + Long userId = 10L; + + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + + ReviewCreateRequest req = new ReviewCreateRequest(); + req.setEthicalPolicyRatings( + Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4)); + req.setTitle("x"); + req.setContent("y"); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(true); + + assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.REVIEW_ALREADY_EXISTS); + } + + @Test + void createReview_whenWorkplaceNotFound_throwsHandleException() { + Long workplaceId = 1L; + User user = sampleUser(10L); + ReviewCreateRequest req = new ReviewCreateRequest(); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.WORKPLACE_NOT_FOUND); + } + + @Test + void createReview_whenWorkplaceHasNoEthicalTags_throwsHandleException() { + Long workplaceId = 1L; + Long userId = 10L; + Workplace wp = sampleWorkplace(workplaceId); + wp.setEthicalTags(Collections.emptySet()); + User user = sampleUser(userId); + + ReviewCreateRequest req = new ReviewCreateRequest(); + req.setEthicalPolicyRatings( + Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4)); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + @Test + void createReview_whenPolicyRatingsNull_throwsHandleException() { + Long workplaceId = 1L; + Long userId = 10L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + + ReviewCreateRequest req = new ReviewCreateRequest(); + req.setEthicalPolicyRatings(null); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + @Test + void createReview_whenScoreOutOfRange_throwsHandleException() { + Long workplaceId = 1L; + Long userId = 10L; + + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + + ReviewCreateRequest req = new ReviewCreateRequest(); + req.setTitle("Bad score"); + req.setContent("Test"); + req.setEthicalPolicyRatings( + Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 6)); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + @Test + void createReview_whenUnknownPolicyLabel_throwsHandleException() { + Long workplaceId = 1L; + Long userId = 10L; + + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + + ReviewCreateRequest req = new ReviewCreateRequest(); + req.setTitle("Unknown policy"); + req.setContent("Test"); + req.setEthicalPolicyRatings( + Map.of("UnknownPolicy", 4)); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + @Test + void createReview_whenPolicyNotDeclaredByWorkplace_throwsHandleException() { + Long workplaceId = 1L; + Long userId = 10L; + + Workplace wp = sampleWorkplace(workplaceId); + wp.setEthicalTags(Set.of(EthicalPolicy.EQUAL_PAY_POLICY)); + User user = sampleUser(userId); + + ReviewCreateRequest req = new ReviewCreateRequest(); + req.setTitle("Undeclared policy"); + req.setContent("Test"); + req.setEthicalPolicyRatings( + Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4)); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) + .thenReturn(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + // ========== LIST REVIEWS ========== + + @Test + void listReviews_whenNoFilters_returnsPaginatedReviews() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(10L); + Profile profile = sampleProfile(user.getId()); + + Review review = sampleReview(100L, wp, user); + Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(reviewRepository.findByWorkplace_Id(eq(workplaceId), any(Pageable.class))) + .thenReturn(page); + + when(reviewPolicyRatingRepository.findByReview_Id(100L)) + .thenReturn(List.of( + ReviewPolicyRating.builder() + .id(1L) + .review(review) + .policy(EthicalPolicy.SALARY_TRANSPARENCY) + .score(4) + .build())); + when(reviewReplyRepository.findByReview_Id(100L)) + .thenReturn(Optional.empty()); + when(profileRepository.findByUserId(user.getId())) + .thenReturn(Optional.of(profile)); + + PaginatedResponse res = reviewService.listReviews( + workplaceId, 0, 10, + null, null, null, + null, null, null); + + assertThat(res).isNotNull(); + assertThat(res.getContent()).hasSize(1); + ReviewResponse item = res.getContent().getFirst(); + assertThat(item.getId()).isEqualTo(100L); + assertThat(item.getOverallRating()).isEqualTo(4.0); + } + + @Test + void listReviews_whenRatingFilterGiven_usesRatingQuery() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(10L); + Profile profile = sampleProfile(user.getId()); + Review review = sampleReview(101L, wp, user); + + Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(reviewRepository.findByWorkplace_IdAndOverallRatingIn( + eq(workplaceId), anyList(), any(Pageable.class))).thenReturn(page); + + when(reviewPolicyRatingRepository.findByReview_Id(101L)) + .thenReturn(List.of( + ReviewPolicyRating.builder() + .id(1L) + .review(review) + .policy(EthicalPolicy.SALARY_TRANSPARENCY) + .score(4) + .build())); + when(reviewReplyRepository.findByReview_Id(101L)) + .thenReturn(Optional.empty()); + when(profileRepository.findByUserId(user.getId())) + .thenReturn(Optional.of(profile)); + + PaginatedResponse res = reviewService.listReviews( + workplaceId, 0, 10, + "4", "rating", null, + null, null, null); + + assertThat(res).isNotNull(); + assertThat(res.getContent()).hasSize(1); + ReviewResponse item = res.getContent().getFirst(); + assertThat(item.getOverallRating()).isEqualTo(4.0); + verify(reviewRepository).findByWorkplace_IdAndOverallRatingIn(eq(workplaceId), anyList(), + any(Pageable.class)); + } + + @Test + void listReviews_whenRatingFilterRange_usesBetweenQuery() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(10L); + Profile profile = sampleProfile(user.getId()); + Review review = sampleReview(103L, wp, user); + + Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(reviewRepository.findByWorkplace_IdAndOverallRatingBetween( + eq(workplaceId), anyDouble(), anyDouble(), any(Pageable.class))).thenReturn(page); + + when(reviewPolicyRatingRepository.findByReview_Id(103L)) + .thenReturn(Collections.emptyList()); + when(reviewReplyRepository.findByReview_Id(103L)) + .thenReturn(Optional.empty()); + when(profileRepository.findByUserId(user.getId())) + .thenReturn(Optional.of(profile)); + + PaginatedResponse res = reviewService.listReviews( + workplaceId, 0, 10, + "3.5,4.5", "ratingDesc", null, + null, null, null); + + assertThat(res).isNotNull(); + assertThat(res.getContent()).hasSize(1); + verify(reviewRepository).findByWorkplace_IdAndOverallRatingBetween( + eq(workplaceId), anyDouble(), anyDouble(), any(Pageable.class)); + } + + @Test + void listReviews_whenWorkplaceNotFound_throwsHandleException() { + Long workplaceId = 1L; + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reviewService.listReviews( + workplaceId, 0, 10, + null, null, null, + null, null, null)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.WORKPLACE_NOT_FOUND); + } + + @Test + void listReviews_whenHasCommentTrue_usesCommentQuery() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(10L); + Profile profile = sampleProfile(user.getId()); + Review review = sampleReview(102L, wp, user); + review.setContent("Non-empty comment"); + + Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + when(reviewRepository.findByWorkplace_IdAndContentIsNotNullAndContentNot( + eq(workplaceId), eq(""), any(Pageable.class))).thenReturn(page); + + when(reviewPolicyRatingRepository.findByReview_Id(102L)) + .thenReturn(Collections.emptyList()); + when(reviewReplyRepository.findByReview_Id(102L)) + .thenReturn(Optional.empty()); + when(profileRepository.findByUserId(user.getId())) + .thenReturn(Optional.of(profile)); + + PaginatedResponse res = reviewService.listReviews( + workplaceId, 0, 10, + null, null, true, + null, null, null); + + assertThat(res).isNotNull(); + assertThat(res.getContent()).hasSize(1); + ReviewResponse item = res.getContent().getFirst(); + assertThat(item.getContent()).isEqualTo("Non-empty comment"); + verify(reviewRepository).findByWorkplace_IdAndContentIsNotNullAndContentNot(eq(workplaceId), eq(""), + any(Pageable.class)); + } + + // ========== GET ONE ========== + + @Test + void getOne_whenValidRequest_returnsResponse() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(10L); + Profile profile = sampleProfile(user.getId()); + Review review = sampleReview(200L, wp, user); + + when(reviewRepository.findById(200L)).thenReturn(Optional.of(review)); + when(reviewPolicyRatingRepository.findByReview_Id(200L)) + .thenReturn(Collections.emptyList()); + when(reviewReplyRepository.findByReview_Id(200L)) + .thenReturn(Optional.empty()); + when(profileRepository.findByUserId(user.getId())) + .thenReturn(Optional.of(profile)); + + ReviewResponse res = reviewService.getOne(workplaceId, 200L, null); + + assertThat(res).isNotNull(); + assertThat(res.getId()).isEqualTo(200L); + assertThat(res.getWorkplaceId()).isEqualTo(workplaceId); + } + + @Test + void getOne_whenWorkplaceMismatch_throwsHandleException() { + Long workplaceId = 1L; + Workplace wpOther = sampleWorkplace(2L); + User user = sampleUser(10L); + Review review = sampleReview(201L, wpOther, user); + + when(reviewRepository.findById(201L)).thenReturn(Optional.of(review)); + + assertThatThrownBy(() -> reviewService.getOne(workplaceId, 201L, null)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); + } + + @Test + void getOne_whenReviewNotFound_throwsHandleException() { + Long workplaceId = 1L; + when(reviewRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reviewService.getOne(workplaceId, 999L, null)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); + } + + // ========== UPDATE REVIEW ========== + + @Test + void updateReview_whenOwnerUpdates_updatesFieldsAndPolicyRatings() { + Long workplaceId = 1L; + Long userId = 10L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + + Review existing = sampleReview(200L, wp, user); + existing.setOverallRating(3.0); + + when(reviewRepository.findById(200L)).thenReturn(Optional.of(existing)); + + ReviewPolicyRating rpr = ReviewPolicyRating.builder() .id(1L) - .review(null) + .review(existing) .policy(EthicalPolicy.SALARY_TRANSPARENCY) - .score(4) - .build() - )); - when(reviewReplyRepository.findByReview_Id(anyLong())) - .thenReturn(Optional.empty()); - when(profileRepository.findByUserId(userId)) - .thenReturn(Optional.of(profile)); - - ReviewResponse res = reviewService.createReview(workplaceId, req, user); - - assertThat(res).isNotNull(); - assertThat(res.getWorkplaceId()).isEqualTo(workplaceId); - assertThat(res.getTitle()).isEqualTo("Great workplace"); - assertThat(res.getOverallRating()).isEqualTo(4.0); - assertThat(res.getEthicalPolicyRatings()) - .containsEntry(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4); - - verify(workplaceRepository).save(wp); - verify(reviewPolicyRatingRepository, times(1)).findByReview_Id(anyLong()); - } - - @Test - void createReview_whenEmployerOfWorkplace_throwsHandleException() { - Long workplaceId = 1L; - Long userId = 10L; - - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(userId); - - ReviewCreateRequest req = new ReviewCreateRequest(); - req.setEthicalPolicyRatings( - Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4) - ); - req.setTitle("x"); - req.setContent("y"); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(true); - - assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.WORKPLACE_UNAUTHORIZED); - } - - @Test - void createReview_whenAlreadyReviewed_throwsHandleException() { - Long workplaceId = 1L; - Long userId = 10L; - - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(userId); - - ReviewCreateRequest req = new ReviewCreateRequest(); - req.setEthicalPolicyRatings( - Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4) - ); - req.setTitle("x"); - req.setContent("y"); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(true); - - assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.REVIEW_ALREADY_EXISTS); - } - - @Test - void createReview_whenWorkplaceNotFound_throwsHandleException() { - Long workplaceId = 1L; - User user = sampleUser(10L); - ReviewCreateRequest req = new ReviewCreateRequest(); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.WORKPLACE_NOT_FOUND); - } - - @Test - void createReview_whenWorkplaceHasNoEthicalTags_throwsHandleException() { - Long workplaceId = 1L; - Long userId = 10L; - Workplace wp = sampleWorkplace(workplaceId); - wp.setEthicalTags(Collections.emptySet()); - User user = sampleUser(userId); - - ReviewCreateRequest req = new ReviewCreateRequest(); - req.setEthicalPolicyRatings( - Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4) - ); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.VALIDATION_ERROR); - } - - @Test - void createReview_whenPolicyRatingsNull_throwsHandleException() { - Long workplaceId = 1L; - Long userId = 10L; - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(userId); - - ReviewCreateRequest req = new ReviewCreateRequest(); - req.setEthicalPolicyRatings(null); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.VALIDATION_ERROR); - } - - @Test - void createReview_whenScoreOutOfRange_throwsHandleException() { - Long workplaceId = 1L; - Long userId = 10L; - - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(userId); - - ReviewCreateRequest req = new ReviewCreateRequest(); - req.setTitle("Bad score"); - req.setContent("Test"); - req.setEthicalPolicyRatings( - Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 6) - ); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.VALIDATION_ERROR); - } - - @Test - void createReview_whenUnknownPolicyLabel_throwsHandleException() { - Long workplaceId = 1L; - Long userId = 10L; - - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(userId); - - ReviewCreateRequest req = new ReviewCreateRequest(); - req.setTitle("Unknown policy"); - req.setContent("Test"); - req.setEthicalPolicyRatings( - Map.of("UnknownPolicy", 4) - ); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.VALIDATION_ERROR); - } - - @Test - void createReview_whenPolicyNotDeclaredByWorkplace_throwsHandleException() { - Long workplaceId = 1L; - Long userId = 10L; - - Workplace wp = sampleWorkplace(workplaceId); - wp.setEthicalTags(Set.of(EthicalPolicy.EQUAL_PAY_POLICY)); - User user = sampleUser(userId); - - ReviewCreateRequest req = new ReviewCreateRequest(); - req.setTitle("Undeclared policy"); - req.setContent("Test"); - req.setEthicalPolicyRatings( - Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4) - ); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(employerWorkplaceRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(reviewRepository.existsByWorkplace_IdAndUser_Id(workplaceId, userId)) - .thenReturn(false); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - assertThatThrownBy(() -> reviewService.createReview(workplaceId, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.VALIDATION_ERROR); - } - - // ========== LIST REVIEWS ========== - - @Test - void listReviews_whenNoFilters_returnsPaginatedReviews() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(10L); - Profile profile = sampleProfile(user.getId()); - - Review review = sampleReview(100L, wp, user); - Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(reviewRepository.findByWorkplace_Id(eq(workplaceId), any(Pageable.class))) - .thenReturn(page); - - when(reviewPolicyRatingRepository.findByReview_Id(100L)) - .thenReturn(List.of( - ReviewPolicyRating.builder() + .score(3) + .build(); + + when(reviewPolicyRatingRepository.findByReview_Id(200L)) + .thenReturn(List.of(rpr)); + + when(reviewRepository.save(any(Review.class))).thenAnswer(invocation -> { + Review r = invocation.getArgument(0); + if (r.getCreatedAt() == null) + r.setCreatedAt(Instant.now()); + if (r.getUpdatedAt() == null) + r.setUpdatedAt(Instant.now()); + return r; + }); + + when(reviewReplyRepository.findByReview_Id(200L)) + .thenReturn(Optional.empty()); + when(profileRepository.findByUserId(userId)) + .thenReturn(Optional.of(sampleProfile(userId))); + + ReviewUpdateRequest req = new ReviewUpdateRequest(); + req.setTitle("Updated title"); + req.setContent("Updated content"); + Map updatedPolicies = new HashMap<>(); + updatedPolicies.put(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 5); + req.setEthicalPolicyRatings(updatedPolicies); + + ReviewResponse res = reviewService.updateReview(workplaceId, 200L, req, user); + + assertThat(res.getTitle()).isEqualTo("Updated title"); + assertThat(res.getContent()).isEqualTo("Updated content"); + assertThat(res.getOverallRating()).isEqualTo(5.0); + + verify(reviewPolicyRatingRepository, atLeastOnce()).findByReview_Id(200L); + verify(reviewRepository, atLeastOnce()).save(existing); + } + + @Test + void updateReview_whenNotOwner_throwsHandleException() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + User owner = sampleUser(10L); + User other = sampleUser(99L); + + Review existing = sampleReview(300L, wp, owner); + + when(reviewRepository.findById(300L)).thenReturn(Optional.of(existing)); + + ReviewUpdateRequest req = new ReviewUpdateRequest(); + req.setTitle("Should not matter"); + + assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 300L, req, other)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + @Test + void updateReview_whenReviewNotFound_throwsHandleException() { + Long workplaceId = 1L; + User user = sampleUser(10L); + ReviewUpdateRequest req = new ReviewUpdateRequest(); + + when(reviewRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 999L, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); + } + + @Test + void updateReview_whenWorkplaceMismatch_throwsHandleException() { + Long workplaceId = 1L; + Workplace otherWp = sampleWorkplace(2L); + User user = sampleUser(10L); + Review review = sampleReview(500L, otherWp, user); + + when(reviewRepository.findById(500L)).thenReturn(Optional.of(review)); + + ReviewUpdateRequest req = new ReviewUpdateRequest(); + req.setTitle("test"); + + assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 500L, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); + } + + @Test + void updateReview_whenPolicyScoreOutOfRange_throwsHandleException() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(10L); + Review review = sampleReview(600L, wp, user); + + when(reviewRepository.findById(600L)).thenReturn(Optional.of(review)); + when(reviewPolicyRatingRepository.findByReview_Id(600L)).thenReturn(Collections.emptyList()); + + ReviewUpdateRequest req = new ReviewUpdateRequest(); + req.setEthicalPolicyRatings( + Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 0)); + + assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 600L, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + @Test + void updateReview_whenUnknownPolicyLabel_throwsHandleException() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(10L); + Review review = sampleReview(601L, wp, user); + + when(reviewRepository.findById(601L)).thenReturn(Optional.of(review)); + when(reviewPolicyRatingRepository.findByReview_Id(601L)).thenReturn(Collections.emptyList()); + + ReviewUpdateRequest req = new ReviewUpdateRequest(); + req.setEthicalPolicyRatings( + Map.of("UnknownPolicy", 4)); + + assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 601L, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + @Test + void updateReview_whenPolicyNotDeclaredByWorkplace_throwsHandleException() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + wp.setEthicalTags(Set.of(EthicalPolicy.EQUAL_PAY_POLICY)); + User user = sampleUser(10L); + Review review = sampleReview(602L, wp, user); + + when(reviewRepository.findById(602L)).thenReturn(Optional.of(review)); + when(reviewPolicyRatingRepository.findByReview_Id(602L)).thenReturn(Collections.emptyList()); + + ReviewUpdateRequest req = new ReviewUpdateRequest(); + req.setEthicalPolicyRatings( + Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4)); + + assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 602L, req, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + // ========== DELETE REVIEW ========== + + @Test + void deleteReview_whenOwnerDeletes_removesReviewAndRelatedEntities() { + Long workplaceId = 1L; + Long userId = 10L; + + Workplace wp = sampleWorkplace(workplaceId); + wp.setReviewCount(1L); + User user = sampleUser(userId); + Review review = sampleReview(300L, wp, user); + + ReviewReply reply = ReviewReply.builder() .id(1L) .review(review) + .content("Thanks for feedback") + .build(); + + ReviewPolicyRating rating1 = ReviewPolicyRating.builder() + .id(10L) + .review(review) .policy(EthicalPolicy.SALARY_TRANSPARENCY) .score(4) - .build() - )); - when(reviewReplyRepository.findByReview_Id(100L)) - .thenReturn(Optional.empty()); - when(profileRepository.findByUserId(user.getId())) - .thenReturn(Optional.of(profile)); - - PaginatedResponse res = reviewService.listReviews( - workplaceId, 0, 10, - null, null, null, - null, null - ); - - assertThat(res).isNotNull(); - assertThat(res.getContent()).hasSize(1); - ReviewResponse item = res.getContent().getFirst(); - assertThat(item.getId()).isEqualTo(100L); - assertThat(item.getOverallRating()).isEqualTo(4.0); - } - - @Test - void listReviews_whenRatingFilterGiven_usesRatingQuery() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(10L); - Profile profile = sampleProfile(user.getId()); - Review review = sampleReview(101L, wp, user); - - Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(reviewRepository.findByWorkplace_IdAndOverallRatingIn( - eq(workplaceId), anyList(), any(Pageable.class) - )).thenReturn(page); - - when(reviewPolicyRatingRepository.findByReview_Id(101L)) - .thenReturn(List.of( - ReviewPolicyRating.builder() + .build(); + + when(reviewRepository.findById(300L)).thenReturn(Optional.of(review)); + when(reviewReplyRepository.findByReview_Id(300L)).thenReturn(Optional.of(reply)); + when(reviewPolicyRatingRepository.findByReview_Id(300L)) + .thenReturn(List.of(rating1)); + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + + reviewService.deleteReview(workplaceId, 300L, user, false); + + verify(reviewReplyRepository).delete(reply); + verify(reviewPolicyRatingRepository).deleteAll(List.of(rating1)); + verify(reviewRepository).delete(review); + verify(workplaceRepository).save(wp); + assertThat(wp.getReviewCount()).isGreaterThanOrEqualTo(0L); + } + + @Test + void deleteReview_whenNotOwnerAndNotAdmin_throwsHandleException() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + User owner = sampleUser(10L); + User other = sampleUser(99L); + Review review = sampleReview(301L, wp, owner); + + when(reviewRepository.findById(301L)).thenReturn(Optional.of(review)); + + assertThatThrownBy(() -> reviewService.deleteReview(workplaceId, 301L, other, false)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.ACCESS_DENIED); + } + + // ========== TOGGLE HELPFUL ========== + + @Test + void toggleHelpful_whenNotHelpfulBefore_createsReactionAndIncrementsCount() { + Long workplaceId = 1L; + Long userId = 10L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + User otherUser = sampleUser(99L); + + Review review = sampleReview(100L, wp, otherUser); + review.setHelpfulCount(5); + + when(reviewRepository.findById(100L)).thenReturn(Optional.of(review)); + when(reviewReactionRepository.findByReview_IdAndUser_Id(100L, userId)) + .thenReturn(Optional.empty()); + + ReviewResponse res = reviewService.toggleHelpful(workplaceId, 100L, user); + + assertThat(res.getHelpfulCount()).isEqualTo(6); + assertThat(res.isHelpfulByUser()).isTrue(); + + verify(reviewReactionRepository).save(any(ReviewReaction.class)); + verify(reviewRepository).save(review); + } + + @Test + void toggleHelpful_whenAlreadyHelpful_deletesReactionAndDecrementsCount() { + Long workplaceId = 1L; + Long userId = 10L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + User otherUser = sampleUser(99L); + + Review review = sampleReview(100L, wp, otherUser); + review.setHelpfulCount(5); + + ReviewReaction reaction = ReviewReaction.builder() .id(1L) .review(review) + .user(user) + .build(); + + when(reviewRepository.findById(100L)).thenReturn(Optional.of(review)); + when(reviewReactionRepository.findByReview_IdAndUser_Id(100L, userId)) + .thenReturn(Optional.of(reaction)); + + ReviewResponse res = reviewService.toggleHelpful(workplaceId, 100L, user); + + assertThat(res.getHelpfulCount()).isEqualTo(4); + assertThat(res.isHelpfulByUser()).isFalse(); + + verify(reviewReactionRepository).delete(reaction); + verify(reviewRepository).save(review); + } + + @Test + void toggleHelpful_whenOwnReview_throwsHandleException() { + Long workplaceId = 1L; + Long userId = 10L; + Workplace wp = sampleWorkplace(workplaceId); + User user = sampleUser(userId); + + Review review = sampleReview(100L, wp, user); + when(reviewRepository.findById(100L)).thenReturn(Optional.of(review)); + + assertThatThrownBy(() -> reviewService.toggleHelpful(workplaceId, 100L, user)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.VALIDATION_ERROR); + + verify(reviewReactionRepository, never()).save(any()); + verify(reviewReactionRepository, never()).delete(any()); + } + + @Test + void deleteReview_whenReviewNotFound_throwsHandleException() { + Long workplaceId = 1L; + User user = sampleUser(10L); + + when(reviewRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reviewService.deleteReview(workplaceId, 999L, user, false)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); + } + + @Test + void deleteReview_whenWorkplaceMismatch_throwsHandleException() { + Long workplaceId = 1L; + Workplace otherWp = sampleWorkplace(2L); + User user = sampleUser(10L); + Review review = sampleReview(700L, otherWp, user); + + when(reviewRepository.findById(700L)).thenReturn(Optional.of(review)); + + assertThatThrownBy(() -> reviewService.deleteReview(workplaceId, 700L, user, false)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); + } + + @Test + void deleteReview_whenWorkplaceNotFoundWhileUpdatingCount_throwsHandleException() { + Long workplaceId = 1L; + User user = sampleUser(10L); + Workplace wp = sampleWorkplace(workplaceId); + Review review = sampleReview(701L, wp, user); + + when(reviewRepository.findById(701L)).thenReturn(Optional.of(review)); + when(reviewReplyRepository.findByReview_Id(701L)).thenReturn(Optional.empty()); + when(reviewPolicyRatingRepository.findByReview_Id(701L)).thenReturn(Collections.emptyList()); + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reviewService.deleteReview(workplaceId, 701L, user, false)) + .isInstanceOf(HandleException.class) + .extracting("code") + .isEqualTo(ErrorCode.WORKPLACE_NOT_FOUND); + } + + @Test + void deleteReview_whenAdminDeletes_removesReviewAndRelatedEntities() { + Long workplaceId = 1L; + Workplace wp = sampleWorkplace(workplaceId); + wp.setReviewCount(1L); + User owner = sampleUser(10L); + User admin = sampleUser(99L); + Review review = sampleReview(401L, wp, owner); + + ReviewReply reply = ReviewReply.builder() + .id(2L) + .review(review) + .content("Admin reply") + .build(); + + ReviewPolicyRating rating = ReviewPolicyRating.builder() + .id(11L) + .review(review) .policy(EthicalPolicy.SALARY_TRANSPARENCY) - .score(4) - .build() - )); - when(reviewReplyRepository.findByReview_Id(101L)) - .thenReturn(Optional.empty()); - when(profileRepository.findByUserId(user.getId())) - .thenReturn(Optional.of(profile)); - - PaginatedResponse res = reviewService.listReviews( - workplaceId, 0, 10, - "4", "rating", null, - null, null - ); - - assertThat(res).isNotNull(); - assertThat(res.getContent()).hasSize(1); - ReviewResponse item = res.getContent().getFirst(); - assertThat(item.getOverallRating()).isEqualTo(4.0); - verify(reviewRepository).findByWorkplace_IdAndOverallRatingIn(eq(workplaceId), anyList(), any(Pageable.class)); - } - - @Test - void listReviews_whenRatingFilterRange_usesBetweenQuery() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(10L); - Profile profile = sampleProfile(user.getId()); - Review review = sampleReview(103L, wp, user); - - Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(reviewRepository.findByWorkplace_IdAndOverallRatingBetween( - eq(workplaceId), anyDouble(), anyDouble(), any(Pageable.class) - )).thenReturn(page); - - when(reviewPolicyRatingRepository.findByReview_Id(103L)) - .thenReturn(Collections.emptyList()); - when(reviewReplyRepository.findByReview_Id(103L)) - .thenReturn(Optional.empty()); - when(profileRepository.findByUserId(user.getId())) - .thenReturn(Optional.of(profile)); - - PaginatedResponse res = reviewService.listReviews( - workplaceId, 0, 10, - "3.5,4.5", "ratingDesc", null, - null, null - ); - - assertThat(res).isNotNull(); - assertThat(res.getContent()).hasSize(1); - verify(reviewRepository).findByWorkplace_IdAndOverallRatingBetween( - eq(workplaceId), anyDouble(), anyDouble(), any(Pageable.class) - ); - } - - @Test - void listReviews_whenWorkplaceNotFound_throwsHandleException() { - Long workplaceId = 1L; - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.listReviews( - workplaceId, 0, 10, - null, null, null, - null, null - )) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.WORKPLACE_NOT_FOUND); - } - - @Test - void listReviews_whenHasCommentTrue_usesCommentQuery() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(10L); - Profile profile = sampleProfile(user.getId()); - Review review = sampleReview(102L, wp, user); - review.setContent("Non-empty comment"); - - Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); - - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - when(reviewRepository.findByWorkplace_IdAndContentIsNotNullAndContentNot( - eq(workplaceId), eq(""), any(Pageable.class) - )).thenReturn(page); - - when(reviewPolicyRatingRepository.findByReview_Id(102L)) - .thenReturn(Collections.emptyList()); - when(reviewReplyRepository.findByReview_Id(102L)) - .thenReturn(Optional.empty()); - when(profileRepository.findByUserId(user.getId())) - .thenReturn(Optional.of(profile)); - - PaginatedResponse res = reviewService.listReviews( - workplaceId, 0, 10, - null, null, true, - null, null - ); - - assertThat(res).isNotNull(); - assertThat(res.getContent()).hasSize(1); - ReviewResponse item = res.getContent().getFirst(); - assertThat(item.getContent()).isEqualTo("Non-empty comment"); - verify(reviewRepository).findByWorkplace_IdAndContentIsNotNullAndContentNot(eq(workplaceId), eq(""), any(Pageable.class)); - } - - // ========== GET ONE ========== - - @Test - void getOne_whenValidRequest_returnsResponse() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(10L); - Profile profile = sampleProfile(user.getId()); - Review review = sampleReview(200L, wp, user); - - when(reviewRepository.findById(200L)).thenReturn(Optional.of(review)); - when(reviewPolicyRatingRepository.findByReview_Id(200L)) - .thenReturn(Collections.emptyList()); - when(reviewReplyRepository.findByReview_Id(200L)) - .thenReturn(Optional.empty()); - when(profileRepository.findByUserId(user.getId())) - .thenReturn(Optional.of(profile)); - - ReviewResponse res = reviewService.getOne(workplaceId, 200L); - - assertThat(res).isNotNull(); - assertThat(res.getId()).isEqualTo(200L); - assertThat(res.getWorkplaceId()).isEqualTo(workplaceId); - } - - @Test - void getOne_whenWorkplaceMismatch_throwsHandleException() { - Long workplaceId = 1L; - Workplace wpOther = sampleWorkplace(2L); - User user = sampleUser(10L); - Review review = sampleReview(201L, wpOther, user); - - when(reviewRepository.findById(201L)).thenReturn(Optional.of(review)); - - assertThatThrownBy(() -> reviewService.getOne(workplaceId, 201L)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); - } - - @Test - void getOne_whenReviewNotFound_throwsHandleException() { - Long workplaceId = 1L; - when(reviewRepository.findById(999L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.getOne(workplaceId, 999L)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); - } - - // ========== UPDATE REVIEW ========== - - @Test - void updateReview_whenOwnerUpdates_updatesFieldsAndPolicyRatings() { - Long workplaceId = 1L; - Long userId = 10L; - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(userId); - - Review existing = sampleReview(200L, wp, user); - existing.setOverallRating(3.0); - - when(reviewRepository.findById(200L)).thenReturn(Optional.of(existing)); - - ReviewPolicyRating rpr = ReviewPolicyRating.builder() - .id(1L) - .review(existing) - .policy(EthicalPolicy.SALARY_TRANSPARENCY) - .score(3) - .build(); - - when(reviewPolicyRatingRepository.findByReview_Id(200L)) - .thenReturn(List.of(rpr)); - - when(reviewRepository.save(any(Review.class))).thenAnswer(invocation -> { - Review r = invocation.getArgument(0); - if (r.getCreatedAt() == null) r.setCreatedAt(Instant.now()); - if (r.getUpdatedAt() == null) r.setUpdatedAt(Instant.now()); - return r; - }); - - when(reviewReplyRepository.findByReview_Id(200L)) - .thenReturn(Optional.empty()); - when(profileRepository.findByUserId(userId)) - .thenReturn(Optional.of(sampleProfile(userId))); - - ReviewUpdateRequest req = new ReviewUpdateRequest(); - req.setTitle("Updated title"); - req.setContent("Updated content"); - Map updatedPolicies = new HashMap<>(); - updatedPolicies.put(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 5); - req.setEthicalPolicyRatings(updatedPolicies); - - ReviewResponse res = reviewService.updateReview(workplaceId, 200L, req, user); - - assertThat(res.getTitle()).isEqualTo("Updated title"); - assertThat(res.getContent()).isEqualTo("Updated content"); - assertThat(res.getOverallRating()).isEqualTo(5.0); - - verify(reviewPolicyRatingRepository, atLeastOnce()).findByReview_Id(200L); - verify(reviewRepository, atLeastOnce()).save(existing); - } - - @Test - void updateReview_whenNotOwner_throwsHandleException() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - User owner = sampleUser(10L); - User other = sampleUser(99L); - - Review existing = sampleReview(300L, wp, owner); - - when(reviewRepository.findById(300L)).thenReturn(Optional.of(existing)); - - ReviewUpdateRequest req = new ReviewUpdateRequest(); - req.setTitle("Should not matter"); - - assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 300L, req, other)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.ACCESS_DENIED); - } - - @Test - void updateReview_whenReviewNotFound_throwsHandleException() { - Long workplaceId = 1L; - User user = sampleUser(10L); - ReviewUpdateRequest req = new ReviewUpdateRequest(); - - when(reviewRepository.findById(999L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 999L, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); - } - - @Test - void updateReview_whenWorkplaceMismatch_throwsHandleException() { - Long workplaceId = 1L; - Workplace otherWp = sampleWorkplace(2L); - User user = sampleUser(10L); - Review review = sampleReview(500L, otherWp, user); - - when(reviewRepository.findById(500L)).thenReturn(Optional.of(review)); - - ReviewUpdateRequest req = new ReviewUpdateRequest(); - req.setTitle("test"); - - assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 500L, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); - } - - @Test - void updateReview_whenPolicyScoreOutOfRange_throwsHandleException() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(10L); - Review review = sampleReview(600L, wp, user); - - when(reviewRepository.findById(600L)).thenReturn(Optional.of(review)); - when(reviewPolicyRatingRepository.findByReview_Id(600L)).thenReturn(Collections.emptyList()); - - ReviewUpdateRequest req = new ReviewUpdateRequest(); - req.setEthicalPolicyRatings( - Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 0) - ); - - assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 600L, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.VALIDATION_ERROR); - } - - @Test - void updateReview_whenUnknownPolicyLabel_throwsHandleException() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - User user = sampleUser(10L); - Review review = sampleReview(601L, wp, user); - - when(reviewRepository.findById(601L)).thenReturn(Optional.of(review)); - when(reviewPolicyRatingRepository.findByReview_Id(601L)).thenReturn(Collections.emptyList()); - - ReviewUpdateRequest req = new ReviewUpdateRequest(); - req.setEthicalPolicyRatings( - Map.of("UnknownPolicy", 4) - ); - - assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 601L, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.VALIDATION_ERROR); - } - - @Test - void updateReview_whenPolicyNotDeclaredByWorkplace_throwsHandleException() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - wp.setEthicalTags(Set.of(EthicalPolicy.EQUAL_PAY_POLICY)); - User user = sampleUser(10L); - Review review = sampleReview(602L, wp, user); - - when(reviewRepository.findById(602L)).thenReturn(Optional.of(review)); - when(reviewPolicyRatingRepository.findByReview_Id(602L)).thenReturn(Collections.emptyList()); - - ReviewUpdateRequest req = new ReviewUpdateRequest(); - req.setEthicalPolicyRatings( - Map.of(EthicalPolicy.SALARY_TRANSPARENCY.getLabel(), 4) - ); - - assertThatThrownBy(() -> reviewService.updateReview(workplaceId, 602L, req, user)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.VALIDATION_ERROR); - } - - // ========== DELETE REVIEW ========== - - @Test - void deleteReview_whenOwnerDeletes_removesReviewAndRelatedEntities() { - Long workplaceId = 1L; - Long userId = 10L; - - Workplace wp = sampleWorkplace(workplaceId); - wp.setReviewCount(1L); - User user = sampleUser(userId); - Review review = sampleReview(300L, wp, user); - - ReviewReply reply = ReviewReply.builder() - .id(1L) - .review(review) - .content("Thanks for feedback") - .build(); - - ReviewPolicyRating rating1 = ReviewPolicyRating.builder() - .id(10L) - .review(review) - .policy(EthicalPolicy.SALARY_TRANSPARENCY) - .score(4) - .build(); - - when(reviewRepository.findById(300L)).thenReturn(Optional.of(review)); - when(reviewReplyRepository.findByReview_Id(300L)).thenReturn(Optional.of(reply)); - when(reviewPolicyRatingRepository.findByReview_Id(300L)) - .thenReturn(List.of(rating1)); - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - - reviewService.deleteReview(workplaceId, 300L, user, false); - - verify(reviewReplyRepository).delete(reply); - verify(reviewPolicyRatingRepository).deleteAll(List.of(rating1)); - verify(reviewRepository).delete(review); - verify(workplaceRepository).save(wp); - assertThat(wp.getReviewCount()).isGreaterThanOrEqualTo(0L); - } - - @Test - void deleteReview_whenNotOwnerAndNotAdmin_throwsHandleException() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - User owner = sampleUser(10L); - User other = sampleUser(99L); - Review review = sampleReview(400L, wp, owner); - - when(reviewRepository.findById(400L)).thenReturn(Optional.of(review)); - - assertThatThrownBy(() -> reviewService.deleteReview(workplaceId, 400L, other, false)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.ACCESS_DENIED); - } - - @Test - void deleteReview_whenReviewNotFound_throwsHandleException() { - Long workplaceId = 1L; - User user = sampleUser(10L); - - when(reviewRepository.findById(999L)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.deleteReview(workplaceId, 999L, user, false)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); - } - - @Test - void deleteReview_whenWorkplaceMismatch_throwsHandleException() { - Long workplaceId = 1L; - Workplace otherWp = sampleWorkplace(2L); - User user = sampleUser(10L); - Review review = sampleReview(700L, otherWp, user); - - when(reviewRepository.findById(700L)).thenReturn(Optional.of(review)); - - assertThatThrownBy(() -> reviewService.deleteReview(workplaceId, 700L, user, false)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND); - } - - @Test - void deleteReview_whenWorkplaceNotFoundWhileUpdatingCount_throwsHandleException() { - Long workplaceId = 1L; - User user = sampleUser(10L); - Workplace wp = sampleWorkplace(workplaceId); - Review review = sampleReview(701L, wp, user); - - when(reviewRepository.findById(701L)).thenReturn(Optional.of(review)); - when(reviewReplyRepository.findByReview_Id(701L)).thenReturn(Optional.empty()); - when(reviewPolicyRatingRepository.findByReview_Id(701L)).thenReturn(Collections.emptyList()); - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.deleteReview(workplaceId, 701L, user, false)) - .isInstanceOf(HandleException.class) - .extracting("code") - .isEqualTo(ErrorCode.WORKPLACE_NOT_FOUND); - } - - @Test - void deleteReview_whenAdminDeletes_removesReviewAndRelatedEntities() { - Long workplaceId = 1L; - Workplace wp = sampleWorkplace(workplaceId); - wp.setReviewCount(1L); - User owner = sampleUser(10L); - User admin = sampleUser(99L); - Review review = sampleReview(401L, wp, owner); - - ReviewReply reply = ReviewReply.builder() - .id(2L) - .review(review) - .content("Admin reply") - .build(); - - ReviewPolicyRating rating = ReviewPolicyRating.builder() - .id(11L) - .review(review) - .policy(EthicalPolicy.SALARY_TRANSPARENCY) - .score(5) - .build(); - - when(reviewRepository.findById(401L)).thenReturn(Optional.of(review)); - when(reviewReplyRepository.findByReview_Id(401L)).thenReturn(Optional.of(reply)); - when(reviewPolicyRatingRepository.findByReview_Id(401L)) - .thenReturn(List.of(rating)); - when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); - - reviewService.deleteReview(workplaceId, 401L, admin, true); - - verify(reviewReplyRepository).delete(reply); - verify(reviewPolicyRatingRepository).deleteAll(List.of(rating)); - verify(reviewRepository).delete(review); - verify(workplaceRepository).save(wp); - assertThat(wp.getReviewCount()).isGreaterThanOrEqualTo(0L); - } + .score(5) + .build(); + + when(reviewRepository.findById(401L)).thenReturn(Optional.of(review)); + when(reviewReplyRepository.findByReview_Id(401L)).thenReturn(Optional.of(reply)); + when(reviewPolicyRatingRepository.findByReview_Id(401L)) + .thenReturn(List.of(rating)); + when(workplaceRepository.findById(workplaceId)).thenReturn(Optional.of(wp)); + + reviewService.deleteReview(workplaceId, 401L, admin, true); + + verify(reviewReplyRepository).delete(reply); + verify(reviewPolicyRatingRepository).deleteAll(List.of(rating)); + verify(reviewRepository).delete(review); + verify(workplaceRepository).save(wp); + assertThat(wp.getReviewCount()).isGreaterThanOrEqualTo(0L); + } } \ No newline at end of file From 2bad2581f5d6ffe16ab61baa6cfe98b8a1ba6728 Mon Sep 17 00:00:00 2001 From: Enver Eren Date: Tue, 2 Dec 2025 22:49:28 +0300 Subject: [PATCH 004/206] test(workplace): fix controller tests --- .../WorkplaceReviewControllerTest.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewControllerTest.java b/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewControllerTest.java index 3c8c2823..2ac25a04 100644 --- a/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewControllerTest.java +++ b/apps/jobboard-backend/src/test/java/org/bounswe/jobboardbackend/workplace/controller/WorkplaceReviewControllerTest.java @@ -220,8 +220,13 @@ void list_whenCalledWithAllParams_delegatesToService() throws Exception { anyLong(), anyInt(), anyInt(), any(), any(), any(), any(), any(), any())) .thenReturn(response); + Authentication auth = new UsernamePasswordAuthenticationToken( + USER_1, + null, + Collections.emptyList()); + mockMvc.perform(get("/api/workplace/{workplaceId}/review", workplaceId) - .with(user("user1@test.com")) + .with(authentication(auth)) .param("page", String.valueOf(page)) .param("size", String.valueOf(size)) .param("ratingFilter", ratingFilter) @@ -270,8 +275,13 @@ void list_whenCalledWithoutParams_usesDefaults() throws Exception { anyLong(), anyInt(), anyInt(), any(), any(), any(), any(), any(), any())) .thenReturn(response); + Authentication auth = new UsernamePasswordAuthenticationToken( + USER_1, + null, + Collections.emptyList()); + mockMvc.perform(get("/api/workplace/{workplaceId}/review", workplaceId) - .with(user("user1@test.com"))) + .with(authentication(auth))) .andExpect(status().isOk()); ArgumentCaptor pageCaptor = ArgumentCaptor.forClass(Integer.class); @@ -304,8 +314,13 @@ void getOne_whenCalled_delegatesToService() throws Exception { ReviewResponse res = new ReviewResponse(); when(reviewService.getOne(eq(workplaceId), eq(reviewId), any())).thenReturn(res); + Authentication auth = new UsernamePasswordAuthenticationToken( + USER_1, + null, + Collections.emptyList()); + mockMvc.perform(get("/api/workplace/{workplaceId}/review/{reviewId}", workplaceId, reviewId) - .with(user("user1@test.com"))) + .with(authentication(auth))) .andExpect(status().isOk()); verify(reviewService).getOne(eq(workplaceId), eq(reviewId), any()); From b3fa0c992164596778020f734b58663f1e9952f8 Mon Sep 17 00:00:00 2001 From: ykaydogdu Date: Fri, 5 Dec 2025 21:01:27 +0300 Subject: [PATCH 005/206] feat: Add helpful vote functionality to reviews --- .../public/locales/ar/common.json | 7 + .../public/locales/en/common.json | 7 + .../public/locales/tr/common.json | 9 +- .../src/components/reviews/ReviewCard.tsx | 56 +- .../src/components/reviews/ReviewList.tsx | 24 +- .../src/hooks/useReviewHelpful.ts | 88 +++ .../src/services/reviews.service.ts | 90 +-- .../src/test/workplace-handlers.ts | 660 ++++++++++-------- .../src/types/workplace.types.ts | 1 + 9 files changed, 579 insertions(+), 363 deletions(-) create mode 100644 apps/jobboard-frontend/src/hooks/useReviewHelpful.ts diff --git a/apps/jobboard-frontend/public/locales/ar/common.json b/apps/jobboard-frontend/public/locales/ar/common.json index 1c82008e..28b76479 100644 --- a/apps/jobboard-frontend/public/locales/ar/common.json +++ b/apps/jobboard-frontend/public/locales/ar/common.json @@ -1016,6 +1016,13 @@ "loadMore": "تحميل المزيد", "loading": "جارٍ التحميل...", "helpful": "مفيد", + "toggleHelpful": "وضع علامة مفيد", + "helpfulMarked": "تم وضع علامة مفيد", + "helpfulMarkedDescription": "تساعد ملاحظاتك الآخرين في العثور على تقييمات مفيدة", + "helpfulRemoved": "تمت إزالة علامة مفيد", + "helpfulRemovedDescription": "لقد أزلت تصويتك المفيد", + "helpfulError": "فشل تحديث التصويت المفيد", + "helpfulErrorDescription": "يرجى المحاولة مرة أخرى لاحقًا", "report": "إبلاغ", "today": "اليوم", "yesterday": "أمس", diff --git a/apps/jobboard-frontend/public/locales/en/common.json b/apps/jobboard-frontend/public/locales/en/common.json index 90e599f4..464bf0c0 100644 --- a/apps/jobboard-frontend/public/locales/en/common.json +++ b/apps/jobboard-frontend/public/locales/en/common.json @@ -1522,6 +1522,13 @@ "loadMore": "Load More Reviews", "loading": "Loading...", "helpful": "Helpful", + "toggleHelpful": "Mark as helpful", + "helpfulMarked": "Marked as helpful", + "helpfulMarkedDescription": "Your feedback helps others find useful reviews", + "helpfulRemoved": "Removed helpful mark", + "helpfulRemovedDescription": "You removed your helpful vote", + "helpfulError": "Failed to update helpful vote", + "helpfulErrorDescription": "Please try again later", "report": "Report", "today": "today", "yesterday": "yesterday", diff --git a/apps/jobboard-frontend/public/locales/tr/common.json b/apps/jobboard-frontend/public/locales/tr/common.json index 92783c1f..0da6eab2 100644 --- a/apps/jobboard-frontend/public/locales/tr/common.json +++ b/apps/jobboard-frontend/public/locales/tr/common.json @@ -1202,6 +1202,13 @@ "loadMore": "Daha Fazla Yükle", "loading": "Yükleniyor...", "helpful": "Yararlı", + "toggleHelpful": "Yararlı olarak işaretle", + "helpfulMarked": "Yararlı olarak işaretlendi", + "helpfulMarkedDescription": "Geri bildiriminiz, başkalarının yararlı değerlendirmeler bulmasına yardımcı olur", + "helpfulRemoved": "Yararlı işareti kaldırıldı", + "helpfulRemovedDescription": "Yararlı oyunuzu kaldırdınız", + "helpfulError": "Yararlı oyu güncellenemedi", + "helpfulErrorDescription": "Lütfen daha sonra tekrar deneyin", "report": "Bildir", "today": "bugün", "yesterday": "dün", @@ -1270,4 +1277,4 @@ "selectConversation": "Bir sohbet seçin", "selectConversationDesc": "Sohbet başlatmak için listeden bir konuşma seçin" } -} \ No newline at end of file +} diff --git a/apps/jobboard-frontend/src/components/reviews/ReviewCard.tsx b/apps/jobboard-frontend/src/components/reviews/ReviewCard.tsx index 4f1da93b..0bd2c022 100644 --- a/apps/jobboard-frontend/src/components/reviews/ReviewCard.tsx +++ b/apps/jobboard-frontend/src/components/reviews/ReviewCard.tsx @@ -11,19 +11,50 @@ import { useState } from 'react'; import { formatDistanceToNow } from 'date-fns'; import { useReportModal } from '@/hooks/useReportModal'; import { reportWorkplaceReview } from '@/services/workplace-report.service'; +import { useReviewHelpful } from '@/hooks/useReviewHelpful'; interface ReviewCardProps { workplaceId: number; review: ReviewResponse; canReply?: boolean; onUpdate?: () => void; + onHelpfulUpdate?: (reviewId: number, newHelpfulCount: number, helpfulByUser?: boolean) => void; } -export function ReviewCard({ workplaceId, review, canReply, onUpdate }: ReviewCardProps) { +export function ReviewCard({ + workplaceId, + review, + canReply, + onUpdate, + onHelpfulUpdate, +}: ReviewCardProps) { const { t } = useTranslation('common'); const [isReplyDialogOpen, setIsReplyDialogOpen] = useState(false); const { openReport, ReportModalElement } = useReportModal(); + // Initialize helpful vote hook + const { + helpfulCount, + userVoted, + isLoading: isHelpfulLoading, + toggleHelpful, + canVote, + } = useReviewHelpful({ + workplaceId, + reviewId: review.id, + initialHelpfulCount: review.helpfulCount, + initialUserVoted: review.helpfulByUser ?? false, // sync initial state from API + }); + + // Handle helpful count updates + const handleHelpfulClick = async () => { + const updatedReview = await toggleHelpful(); + // Notify parent component of helpful count change + if (updatedReview) { + onHelpfulUpdate?.(review.id, updatedReview.helpfulCount, updatedReview.helpfulByUser); + } + }; + const handleReport = () => { openReport({ title: t('reviews.report'), @@ -96,9 +127,7 @@ export function ReviewCard({ workplaceId, review, canReply, onUpdate }: ReviewCa {/* Title */} - {review.title && ( -
{review.title}
- )} + {review.title &&
{review.title}
} {/* Content */} {review.content && ( @@ -123,12 +152,23 @@ export function ReviewCard({ workplaceId, review, canReply, onUpdate }: ReviewCa {/* Actions */}
-
- +
+ {canReply && !review.reply && (
@@ -155,9 +175,7 @@ export function ReviewList({ currentPage > 0 && handlePageChange(currentPage - 1)} className={ - currentPage === 0 - ? 'pointer-events-none opacity-50' - : 'cursor-pointer' + currentPage === 0 ? 'pointer-events-none opacity-50' : 'cursor-pointer' } /> diff --git a/apps/jobboard-frontend/src/hooks/useReviewHelpful.ts b/apps/jobboard-frontend/src/hooks/useReviewHelpful.ts new file mode 100644 index 00000000..892f3005 --- /dev/null +++ b/apps/jobboard-frontend/src/hooks/useReviewHelpful.ts @@ -0,0 +1,88 @@ +/** + * useReviewHelpful Hook + * Manages helpful vote state and operations for reviews + */ + +import { useState, useCallback } from 'react'; +import { markReviewHelpful } from '@/services/reviews.service'; +import type { ReviewResponse } from '@/types/workplace.types'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; + +interface UseReviewHelpfulProps { + workplaceId: number; + reviewId: number; + initialHelpfulCount: number; + initialUserVoted?: boolean; +} + +interface UseReviewHelpfulReturn { + helpfulCount: number; + userVoted: boolean; + isLoading: boolean; + toggleHelpful: () => Promise; + canVote: boolean; +} + +export function useReviewHelpful({ + workplaceId, + reviewId, + initialHelpfulCount, + initialUserVoted = false, +}: UseReviewHelpfulProps): UseReviewHelpfulReturn { + const [helpfulCount, setHelpfulCount] = useState(initialHelpfulCount); + const [userVoted, setUserVoted] = useState(initialUserVoted); + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation('common'); + + const canVote = true; // will be enhanced with auth checks when available + + const toggleHelpful = useCallback(async (): Promise => { + if (isLoading || !canVote) return; + + setIsLoading(true); + + // Optimistic update + const previousCount = helpfulCount; + const previousVoted = userVoted; + const optimisticCount = previousVoted ? previousCount - 1 : previousCount + 1; + + setUserVoted(!previousVoted); + setHelpfulCount(Math.max(optimisticCount, 0)); + + try { + // Backend toggles helpful status via single POST endpoint + const updatedReview = await markReviewHelpful(workplaceId, reviewId); + + const newUserVoted = !!updatedReview.helpfulByUser; + setHelpfulCount(updatedReview.helpfulCount); + setUserVoted(newUserVoted); + + toast.success( + newUserVoted + ? t('reviews.helpfulMarked') || 'Marked as helpful' + : t('reviews.helpfulRemoved') || 'Removed helpful mark', + ); + + return updatedReview; + } catch (error) { + // Revert optimistic update on error + setHelpfulCount(previousCount); + setUserVoted(previousVoted); + + console.error('Failed to toggle helpful vote:', error); + + toast.error(t('reviews.helpfulError') || 'Failed to update helpful vote'); + } finally { + setIsLoading(false); + } + }, [workplaceId, reviewId, helpfulCount, isLoading, canVote, toast, t, userVoted]); + + return { + helpfulCount, + userVoted, + isLoading, + toggleHelpful, + canVote, + }; +} diff --git a/apps/jobboard-frontend/src/services/reviews.service.ts b/apps/jobboard-frontend/src/services/reviews.service.ts index 75e625ec..930182f5 100644 --- a/apps/jobboard-frontend/src/services/reviews.service.ts +++ b/apps/jobboard-frontend/src/services/reviews.service.ts @@ -26,18 +26,15 @@ const BASE_PATH = '/workplace'; */ export async function getWorkplaceReviews( workplaceId: number, - params: ReviewListParams = {} + params: ReviewListParams = {}, ): Promise { - const response = await api.get( - `${BASE_PATH}/${workplaceId}/review`, - { - params: { - page: params.page ?? 0, - size: params.size ?? 10, - ...params, - }, - } - ); + const response = await api.get(`${BASE_PATH}/${workplaceId}/review`, { + params: { + page: params.page ?? 0, + size: params.size ?? 10, + ...params, + }, + }); return response.data; } @@ -46,13 +43,8 @@ export async function getWorkplaceReviews( * @param workplaceId Workplace ID * @param reviewId Review ID */ -export async function getReview( - workplaceId: number, - reviewId: number -): Promise { - const response = await api.get( - `${BASE_PATH}/${workplaceId}/review/${reviewId}` - ); +export async function getReview(workplaceId: number, reviewId: number): Promise { + const response = await api.get(`${BASE_PATH}/${workplaceId}/review/${reviewId}`); return response.data; } @@ -63,12 +55,9 @@ export async function getReview( */ export async function createReview( workplaceId: number, - request: ReviewCreateRequest + request: ReviewCreateRequest, ): Promise { - const response = await api.post( - `${BASE_PATH}/${workplaceId}/review`, - request - ); + const response = await api.post(`${BASE_PATH}/${workplaceId}/review`, request); return response.data; } @@ -81,26 +70,31 @@ export async function createReview( export async function updateReview( workplaceId: number, reviewId: number, - request: ReviewUpdateRequest + request: ReviewUpdateRequest, ): Promise { const response = await api.put( `${BASE_PATH}/${workplaceId}/review/${reviewId}`, - request + request, ); return response.data; } +// ============================================================================ +// Helpful Vote Operations +// ============================================================================ + /** - * Delete a review + * Mark a review as helpful. + * Backend only exposes this POST endpoint for helpful count interaction. * @param workplaceId Workplace ID * @param reviewId Review ID */ -export async function deleteReview( +export async function markReviewHelpful( workplaceId: number, - reviewId: number -): Promise { - const response = await api.delete( - `${BASE_PATH}/${workplaceId}/review/${reviewId}` + reviewId: number, +): Promise { + const response = await api.post( + `${BASE_PATH}/${workplaceId}/review/${reviewId}/helpful`, ); return response.data; } @@ -114,11 +108,11 @@ export async function deleteReview( export async function reportReview( workplaceId: number, reviewId: number, - request: ReviewReportCreate + request: ReviewReportCreate, ): Promise { const response = await api.post( `${BASE_PATH}/${workplaceId}/review/${reviewId}/report`, - request + request, ); return response.data; } @@ -127,21 +121,6 @@ export async function reportReview( // Reply Operations // ============================================================================ -/** - * Get the reply to a review - * @param workplaceId Workplace ID - * @param reviewId Review ID - */ -export async function getReply( - workplaceId: number, - reviewId: number -): Promise { - const response = await api.get( - `${BASE_PATH}/${workplaceId}/review/${reviewId}/reply` - ); - return response.data; -} - /** * Create a reply to a review (employer only) * @param workplaceId Workplace ID @@ -151,11 +130,11 @@ export async function getReply( export async function createReply( workplaceId: number, reviewId: number, - request: ReplyCreateRequest + request: ReplyCreateRequest, ): Promise { const response = await api.post( `${BASE_PATH}/${workplaceId}/review/${reviewId}/reply`, - request + request, ); return response.data; } @@ -169,11 +148,11 @@ export async function createReply( export async function updateReply( workplaceId: number, reviewId: number, - request: ReplyUpdateRequest + request: ReplyUpdateRequest, ): Promise { const response = await api.put( `${BASE_PATH}/${workplaceId}/review/${reviewId}/reply`, - request + request, ); return response.data; } @@ -183,12 +162,9 @@ export async function updateReply( * @param workplaceId Workplace ID * @param reviewId Review ID */ -export async function deleteReply( - workplaceId: number, - reviewId: number -): Promise { +export async function deleteReply(workplaceId: number, reviewId: number): Promise { const response = await api.delete( - `${BASE_PATH}/${workplaceId}/review/${reviewId}/reply` + `${BASE_PATH}/${workplaceId}/review/${reviewId}/reply`, ); return response.data; } diff --git a/apps/jobboard-frontend/src/test/workplace-handlers.ts b/apps/jobboard-frontend/src/test/workplace-handlers.ts index 76e60c08..c2404610 100644 --- a/apps/jobboard-frontend/src/test/workplace-handlers.ts +++ b/apps/jobboard-frontend/src/test/workplace-handlers.ts @@ -1,315 +1,387 @@ import { http, HttpResponse } from 'msw'; import { API_BASE_URL } from './handlers'; import type { - WorkplaceBriefResponse, - WorkplaceDetailResponse, - WorkplaceCreateRequest, - WorkplaceUpdateRequest, - PaginatedWorkplaceResponse, - EmployerWorkplaceBrief, + WorkplaceBriefResponse, + WorkplaceDetailResponse, + WorkplaceCreateRequest, + WorkplaceUpdateRequest, + PaginatedWorkplaceResponse, + EmployerWorkplaceBrief, } from '@/types/workplace.types'; // Mock Data const mockWorkplaces: WorkplaceBriefResponse[] = [ - { - id: 1, - companyName: 'Tech Corp', - imageUrl: 'https://example.com/tech-corp.jpg', - sector: 'Technology', - location: 'San Francisco, CA', - shortDescription: 'Leading tech innovation', - overallAvg: 4.5, - ethicalTags: ['Sustainability', 'Diversity'], - ethicalAverages: { 'Sustainability': 4.0, 'Diversity': 5.0 } - }, - { - id: 2, - companyName: 'Green Energy Inc', - imageUrl: 'https://example.com/green-energy.jpg', - sector: 'Energy', - location: 'Austin, TX', - shortDescription: 'Renewable energy solutions', - overallAvg: 4.8, - ethicalTags: ['Environment', 'Fair Trade'], - ethicalAverages: { 'Environment': 5.0, 'Fair Trade': 4.6 } - }, - { - id: 3, - companyName: 'Health Plus', - imageUrl: undefined, - sector: 'Healthcare', - location: 'Boston, MA', - shortDescription: 'Healthcare for everyone', - overallAvg: 3.9, - ethicalTags: ['Healthcare Access'], - ethicalAverages: { 'Healthcare Access': 3.9 } - } -]; - -const mockWorkplaceDetail: WorkplaceDetailResponse = { + { id: 1, companyName: 'Tech Corp', imageUrl: 'https://example.com/tech-corp.jpg', sector: 'Technology', location: 'San Francisco, CA', shortDescription: 'Leading tech innovation', - detailedDescription: 'We are a global leader in technology innovation, committed to creating a better future through software.', - website: 'https://techcorp.example.com', - ethicalTags: ['Sustainability', 'Diversity'], overallAvg: 4.5, - ethicalAverages: { 'Sustainability': 4.0, 'Diversity': 5.0 }, - reviewCount: 120, - employers: [ - { - userId: 1, - username: 'employer1', - email: 'employer1@techcorp.com', - role: 'ADMIN', - joinedAt: '2023-01-01' - } - ], - recentReviews: [ - { - id: 101, - workplaceId: 1, - userId: 5, - username: 'user5', - title: 'Great place to work', - content: 'I learned a lot here.', - anonymous: false, - helpfulCount: 5, - overallRating: 5, - ethicalPolicyRatings: { 'Sustainability': 5 }, - createdAt: '2024-01-10', - updatedAt: '2024-01-10' - } - ], - createdAt: '2023-01-01', - updatedAt: '2024-01-01' + ethicalTags: ['Sustainability', 'Diversity'], + ethicalAverages: { Sustainability: 4.0, Diversity: 5.0 }, + }, + { + id: 2, + companyName: 'Green Energy Inc', + imageUrl: 'https://example.com/green-energy.jpg', + sector: 'Energy', + location: 'Austin, TX', + shortDescription: 'Renewable energy solutions', + overallAvg: 4.8, + ethicalTags: ['Environment', 'Fair Trade'], + ethicalAverages: { Environment: 5.0, 'Fair Trade': 4.6 }, + }, + { + id: 3, + companyName: 'Health Plus', + imageUrl: undefined, + sector: 'Healthcare', + location: 'Boston, MA', + shortDescription: 'Healthcare for everyone', + overallAvg: 3.9, + ethicalTags: ['Healthcare Access'], + ethicalAverages: { 'Healthcare Access': 3.9 }, + }, +]; + +const mockWorkplaceDetail: WorkplaceDetailResponse = { + id: 1, + companyName: 'Tech Corp', + imageUrl: 'https://example.com/tech-corp.jpg', + sector: 'Technology', + location: 'San Francisco, CA', + shortDescription: 'Leading tech innovation', + detailedDescription: + 'We are a global leader in technology innovation, committed to creating a better future through software.', + website: 'https://techcorp.example.com', + ethicalTags: ['Sustainability', 'Diversity'], + overallAvg: 4.5, + ethicalAverages: { Sustainability: 4.0, Diversity: 5.0 }, + reviewCount: 120, + employers: [ + { + userId: 1, + username: 'employer1', + email: 'employer1@techcorp.com', + role: 'ADMIN', + joinedAt: '2023-01-01', + }, + ], + recentReviews: [ + { + id: 101, + workplaceId: 1, + userId: 5, + username: 'user5', + title: 'Great place to work', + content: 'I learned a lot here.', + anonymous: false, + helpfulCount: 5, + overallRating: 5, + ethicalPolicyRatings: { Sustainability: 5 }, + createdAt: '2024-01-10', + updatedAt: '2024-01-10', + }, + ], + createdAt: '2023-01-01', + updatedAt: '2024-01-01', }; const mockEmployerWorkplaces: EmployerWorkplaceBrief[] = [ - { - role: 'ADMIN', - workplace: mockWorkplaces[0] - } + { + role: 'ADMIN', + workplace: mockWorkplaces[0], + }, ]; export const workplaceHandlers = [ - // Get Workplaces (List) - http.get(`${API_BASE_URL}/workplace`, async ({ request }) => { - const url = new URL(request.url); - const page = Number(url.searchParams.get('page') || 0); - const size = Number(url.searchParams.get('size') || 12); - const search = url.searchParams.get('search')?.toLowerCase(); - const sector = url.searchParams.get('sector'); - const location = url.searchParams.get('location')?.toLowerCase(); - const minRating = Number(url.searchParams.get('minRating') || 0); - - let filtered = [...mockWorkplaces]; - - if (search) { - filtered = filtered.filter(w => w.companyName.toLowerCase().includes(search)); - } - if (sector) { - filtered = filtered.filter(w => w.sector === sector); - } - if (location) { - filtered = filtered.filter(w => w.location.toLowerCase().includes(location)); - } - if (minRating > 0) { - filtered = filtered.filter(w => w.overallAvg >= minRating); - } - - const totalElements = filtered.length; - const totalPages = Math.ceil(totalElements / size); - const content = filtered.slice(page * size, (page + 1) * size); - - const response: PaginatedWorkplaceResponse = { - content, - page, - size, - totalElements, - totalPages, - hasNext: page < totalPages - 1, - hasPrevious: page > 0 - }; - - return HttpResponse.json(response, { status: 200 }); - }), - - // Get Employer Workplaces - http.get(`${API_BASE_URL}/workplace/employers/me`, async () => { - return HttpResponse.json(mockEmployerWorkplaces, { status: 200 }); - }), - - // Get Workplace Detail - http.get(`${API_BASE_URL}/workplace/:id`, async ({ params }) => { - const { id } = params; - const workplaceId = Number(id); - - if (workplaceId === 999) { // Mock not found - return new HttpResponse(null, { status: 404 }); - } - - const workplace = mockWorkplaces.find(w => w.id === workplaceId); - if (workplace) { - // Merge brief info with detail structure for consistency in tests if needed - // For now, returning the static detail mock but overriding ID/Name if it matches list - return HttpResponse.json({ - ...mockWorkplaceDetail, - id: workplace.id, - companyName: workplace.companyName, - sector: workplace.sector - }, { status: 200 }); - } - - return HttpResponse.json(mockWorkplaceDetail, { status: 200 }); - }), - - // Create Workplace - http.post(`${API_BASE_URL}/workplace`, async ({ request }) => { - const body = await request.json() as WorkplaceCreateRequest; - - const newWorkplace: WorkplaceDetailResponse = { - ...mockWorkplaceDetail, - id: Math.floor(Math.random() * 1000) + 100, - companyName: body.companyName, - sector: body.sector, - location: body.location, - shortDescription: body.shortDescription, - detailedDescription: body.detailedDescription, - website: body.website, - ethicalTags: body.ethicalTags || [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }; - - return HttpResponse.json(newWorkplace, { status: 201 }); - }), - - // Update Workplace - http.put(`${API_BASE_URL}/workplace/:id`, async ({ request, params }) => { - const body = await request.json() as WorkplaceUpdateRequest; - const { id } = params; - - return HttpResponse.json({ - ...mockWorkplaceDetail, - id: Number(id), - ...body - }, { status: 200 }); - }), - - // Upload Image - http.post(`${API_BASE_URL}/workplace/:id/image`, async () => { - return HttpResponse.json({ - imageUrl: 'https://example.com/new-image.jpg', - updatedAt: new Date().toISOString() - }, { status: 200 }); - }), - - // Delete Image - http.delete(`${API_BASE_URL}/workplace/:id/image`, async () => { - return HttpResponse.json({ - message: 'Image deleted successfully', - timestamp: new Date().toISOString() - }, { status: 200 }); - }), - - // Join Workplace Request - http.post(`${API_BASE_URL}/workplace/:id/join`, async () => { - return HttpResponse.json({ - message: 'Join request sent successfully', - timestamp: new Date().toISOString() - }, { status: 200 }); - }), - - // Get Employer Requests for a Workplace - http.get(`${API_BASE_URL}/workplace/:id/employers/request`, async ({ request }) => { - const url = new URL(request.url); - const page = Number(url.searchParams.get('page') || 0); - const size = Number(url.searchParams.get('size') || 10); - - // Return empty list by default (no pending requests) - return HttpResponse.json({ - content: [], - page, - size, - totalElements: 0, - totalPages: 0, - hasNext: false, - hasPrevious: false - }, { status: 200 }); - }), - - // Get My Employer Requests - http.get(`${API_BASE_URL}/workplace/employers/requests/me`, async ({ request }) => { - const url = new URL(request.url); - const page = Number(url.searchParams.get('page') || 0); - const size = Number(url.searchParams.get('size') || 10); - - // Return empty list by default - return HttpResponse.json({ - content: [], - page, - size, - totalElements: 0, - totalPages: 0, - hasNext: false, - hasPrevious: false - }, { status: 200 }); - }), - - // Get Workplace Reviews - http.get(`${API_BASE_URL}/workplace/:id/review`, async ({ params, request }) => { - const { id } = params; - const url = new URL(request.url); - const page = Number(url.searchParams.get('page') || 0); - const size = Number(url.searchParams.get('size') || 10); - - const mockReviews = [ - { - id: 101, - workplaceId: Number(id), - userId: 5, - username: 'user5', - title: 'Great place to work', - content: 'I learned a lot here.', - anonymous: false, - helpfulCount: 5, - overallRating: 5, - ethicalPolicyRatings: { 'Sustainability': 5 }, - createdAt: '2024-01-10', - updatedAt: '2024-01-10' - }, - { - id: 102, - workplaceId: Number(id), - userId: 6, - username: 'user6', - title: 'Excellent workplace', - content: 'Very ethical company.', - anonymous: false, - helpfulCount: 3, - overallRating: 4, - ethicalPolicyRatings: { 'Diversity': 4 }, - createdAt: '2024-01-09', - updatedAt: '2024-01-09' - } - ]; - - const totalElements = mockReviews.length; - const totalPages = Math.ceil(totalElements / size); - const content = mockReviews.slice(page * size, (page + 1) * size); - - return HttpResponse.json({ - content, - page, - size, - totalElements, - totalPages, - hasNext: page < totalPages - 1, - hasPrevious: page > 0 - }, { status: 200 }); - }) + // Get Workplaces (List) + http.get(`${API_BASE_URL}/workplace`, async ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get('page') || 0); + const size = Number(url.searchParams.get('size') || 12); + const search = url.searchParams.get('search')?.toLowerCase(); + const sector = url.searchParams.get('sector'); + const location = url.searchParams.get('location')?.toLowerCase(); + const minRating = Number(url.searchParams.get('minRating') || 0); + + let filtered = [...mockWorkplaces]; + + if (search) { + filtered = filtered.filter((w) => w.companyName.toLowerCase().includes(search)); + } + if (sector) { + filtered = filtered.filter((w) => w.sector === sector); + } + if (location) { + filtered = filtered.filter((w) => w.location.toLowerCase().includes(location)); + } + if (minRating > 0) { + filtered = filtered.filter((w) => w.overallAvg >= minRating); + } + + const totalElements = filtered.length; + const totalPages = Math.ceil(totalElements / size); + const content = filtered.slice(page * size, (page + 1) * size); + + const response: PaginatedWorkplaceResponse = { + content, + page, + size, + totalElements, + totalPages, + hasNext: page < totalPages - 1, + hasPrevious: page > 0, + }; + + return HttpResponse.json(response, { status: 200 }); + }), + + // Get Employer Workplaces + http.get(`${API_BASE_URL}/workplace/employers/me`, async () => { + return HttpResponse.json(mockEmployerWorkplaces, { status: 200 }); + }), + + // Get Workplace Detail + http.get(`${API_BASE_URL}/workplace/:id`, async ({ params }) => { + const { id } = params; + const workplaceId = Number(id); + + if (workplaceId === 999) { + // Mock not found + return new HttpResponse(null, { status: 404 }); + } + + const workplace = mockWorkplaces.find((w) => w.id === workplaceId); + if (workplace) { + // Merge brief info with detail structure for consistency in tests if needed + // For now, returning the static detail mock but overriding ID/Name if it matches list + return HttpResponse.json( + { + ...mockWorkplaceDetail, + id: workplace.id, + companyName: workplace.companyName, + sector: workplace.sector, + }, + { status: 200 }, + ); + } + + return HttpResponse.json(mockWorkplaceDetail, { status: 200 }); + }), + + // Create Workplace + http.post(`${API_BASE_URL}/workplace`, async ({ request }) => { + const body = (await request.json()) as WorkplaceCreateRequest; + + const newWorkplace: WorkplaceDetailResponse = { + ...mockWorkplaceDetail, + id: Math.floor(Math.random() * 1000) + 100, + companyName: body.companyName, + sector: body.sector, + location: body.location, + shortDescription: body.shortDescription, + detailedDescription: body.detailedDescription, + website: body.website, + ethicalTags: body.ethicalTags || [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + return HttpResponse.json(newWorkplace, { status: 201 }); + }), + + // Update Workplace + http.put(`${API_BASE_URL}/workplace/:id`, async ({ request, params }) => { + const body = (await request.json()) as WorkplaceUpdateRequest; + const { id } = params; + + return HttpResponse.json( + { + ...mockWorkplaceDetail, + id: Number(id), + ...body, + }, + { status: 200 }, + ); + }), + + // Upload Image + http.post(`${API_BASE_URL}/workplace/:id/image`, async () => { + return HttpResponse.json( + { + imageUrl: 'https://example.com/new-image.jpg', + updatedAt: new Date().toISOString(), + }, + { status: 200 }, + ); + }), + + // Delete Image + http.delete(`${API_BASE_URL}/workplace/:id/image`, async () => { + return HttpResponse.json( + { + message: 'Image deleted successfully', + timestamp: new Date().toISOString(), + }, + { status: 200 }, + ); + }), + + // Join Workplace Request + http.post(`${API_BASE_URL}/workplace/:id/join`, async () => { + return HttpResponse.json( + { + message: 'Join request sent successfully', + timestamp: new Date().toISOString(), + }, + { status: 200 }, + ); + }), + + // Get Employer Requests for a Workplace + http.get(`${API_BASE_URL}/workplace/:id/employers/request`, async ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get('page') || 0); + const size = Number(url.searchParams.get('size') || 10); + + // Return empty list by default (no pending requests) + return HttpResponse.json( + { + content: [], + page, + size, + totalElements: 0, + totalPages: 0, + hasNext: false, + hasPrevious: false, + }, + { status: 200 }, + ); + }), + + // Get My Employer Requests + http.get(`${API_BASE_URL}/workplace/employers/requests/me`, async ({ request }) => { + const url = new URL(request.url); + const page = Number(url.searchParams.get('page') || 0); + const size = Number(url.searchParams.get('size') || 10); + + // Return empty list by default + return HttpResponse.json( + { + content: [], + page, + size, + totalElements: 0, + totalPages: 0, + hasNext: false, + hasPrevious: false, + }, + { status: 200 }, + ); + }), + + // Get Workplace Reviews + http.get(`${API_BASE_URL}/workplace/:id/review`, async ({ params, request }) => { + const { id } = params; + const url = new URL(request.url); + const page = Number(url.searchParams.get('page') || 0); + const size = Number(url.searchParams.get('size') || 10); + + const mockReviews = [ + { + id: 101, + workplaceId: Number(id), + userId: 5, + username: 'user5', + title: 'Great place to work', + content: 'I learned a lot here.', + anonymous: false, + helpfulCount: 5, + overallRating: 5, + ethicalPolicyRatings: { Sustainability: 5 }, + createdAt: '2024-01-10', + updatedAt: '2024-01-10', + }, + { + id: 102, + workplaceId: Number(id), + userId: 6, + username: 'user6', + title: 'Excellent workplace', + content: 'Very ethical company.', + anonymous: false, + helpfulCount: 3, + overallRating: 4, + ethicalPolicyRatings: { Diversity: 4 }, + createdAt: '2024-01-09', + updatedAt: '2024-01-09', + }, + ]; + + const totalElements = mockReviews.length; + const totalPages = Math.ceil(totalElements / size); + const content = mockReviews.slice(page * size, (page + 1) * size); + + return HttpResponse.json( + { + content, + page, + size, + totalElements, + totalPages, + hasNext: page < totalPages - 1, + hasPrevious: page > 0, + }, + { status: 200 }, + ); + }), + + // Mark review as helpful + http.post(`${API_BASE_URL}/workplace/:workplaceId/review/:reviewId/helpful`, async () => { + // Simulate successful helpful vote + return HttpResponse.json( + { + success: true, + helpfulCount: Math.floor(Math.random() * 10) + 1, // Random count for demo + userVoted: true, + message: 'Review marked as helpful', + }, + { status: 200 }, + ); + }), + + // Remove helpful vote from review + http.delete(`${API_BASE_URL}/workplace/:workplaceId/review/:reviewId/helpful`, async () => { + // Simulate successful helpful vote removal + return HttpResponse.json( + { + success: true, + helpfulCount: Math.floor(Math.random() * 10), // Random count for demo + userVoted: false, + message: 'Helpful vote removed', + }, + { status: 200 }, + ); + }), + + // Get user's helpful vote status for multiple reviews + http.get(`${API_BASE_URL}/workplace/:workplaceId/reviews/helpful-status`, async ({ request }) => { + const url = new URL(request.url); + const reviewIds = + url.searchParams + .get('reviewIds') + ?.split(',') + .map((id) => Number(id)) || []; + + // Simulate user vote status - randomly vote on some reviews + const voteStatus: Record = {}; + reviewIds.forEach((reviewId) => { + voteStatus[reviewId] = Math.random() > 0.5; // Randomly voted + }); + + return HttpResponse.json(voteStatus, { status: 200 }); + }), ]; diff --git a/apps/jobboard-frontend/src/types/workplace.types.ts b/apps/jobboard-frontend/src/types/workplace.types.ts index 154cec2c..5e88e673 100644 --- a/apps/jobboard-frontend/src/types/workplace.types.ts +++ b/apps/jobboard-frontend/src/types/workplace.types.ts @@ -97,6 +97,7 @@ export interface ReviewResponse { content?: string; anonymous: boolean; helpfulCount: number; + helpfulByUser?: boolean; overallRating: number; ethicalPolicyRatings: Record; reply?: ReplyResponse; From 03e94951572214f018f93e5271fe1a38732af07c Mon Sep 17 00:00:00 2001 From: ykaydogdu Date: Fri, 5 Dec 2025 21:10:48 +0300 Subject: [PATCH 006/206] test: Enhance WorkplaceProfilePage tests and add useReviewHelpful hook tests --- .../integration/WorkplaceProfilePage.test.tsx | 175 ++++++++++++++++++ .../hooks/__tests__/useReviewHelpful.test.tsx | 130 +++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 apps/jobboard-frontend/src/hooks/__tests__/useReviewHelpful.test.tsx diff --git a/apps/jobboard-frontend/src/__tests__/workplace/integration/WorkplaceProfilePage.test.tsx b/apps/jobboard-frontend/src/__tests__/workplace/integration/WorkplaceProfilePage.test.tsx index 07f0998c..5d6b40fb 100644 --- a/apps/jobboard-frontend/src/__tests__/workplace/integration/WorkplaceProfilePage.test.tsx +++ b/apps/jobboard-frontend/src/__tests__/workplace/integration/WorkplaceProfilePage.test.tsx @@ -1,4 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import WorkplaceProfilePage from '@/pages/WorkplaceProfilePage'; import { describe, it, expect, vi } from 'vitest'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; @@ -91,4 +92,178 @@ describe('WorkplaceProfilePage Integration', () => { expect(screen.getByText('employer1')).toBeInTheDocument(); }); }); + + it('renders helpful initial state from API', async () => { + const pagedReviews = { + content: [ + { + id: 201, + workplaceId: 1, + userId: 5, + username: 'user5', + title: 'Helpful toggle test', + content: 'Toggle me', + anonymous: false, + helpfulCount: 1, + helpfulByUser: false, + overallRating: 4, + ethicalPolicyRatings: {}, + createdAt: '2024-01-10', + updatedAt: '2024-01-10', + }, + { + id: 202, + workplaceId: 1, + userId: 6, + username: 'user6', + title: 'Already voted', + content: 'I voted before', + anonymous: false, + helpfulCount: 5, + helpfulByUser: true, + overallRating: 5, + ethicalPolicyRatings: {}, + createdAt: '2024-01-09', + updatedAt: '2024-01-09', + }, + ], + page: 0, + size: 10, + totalElements: 2, + totalPages: 1, + hasNext: false, + hasPrevious: false, + }; + + server.use( + http.get(`${API_BASE_URL}/workplace/:id/review`, async () => HttpResponse.json(pagedReviews)), + http.post(`${API_BASE_URL}/workplace/:workplaceId/review/:reviewId/helpful`, async () => + HttpResponse.json({ + ...pagedReviews.content[0], + helpfulCount: 2, + helpfulByUser: true, + }), + ), + ); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Helpful toggle test')).toBeInTheDocument(); + expect(screen.getByText('Already voted')).toBeInTheDocument(); + }); + + // Initial counts reflect server data + expect( + screen.getByRole('button', { name: /reviews.helpful \(1\)/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /reviews.helpful \(5\)/i }), + ).toBeInTheDocument(); + }); + + it('toggles helpful on and off in the page', async () => { + const review = { + id: 301, + workplaceId: 1, + userId: 7, + username: 'user7', + title: 'Toggle roundtrip', + content: 'Click twice', + anonymous: false, + helpfulCount: 1, + helpfulByUser: false, + overallRating: 4, + ethicalPolicyRatings: {}, + createdAt: '2024-02-01', + updatedAt: '2024-02-01', + }; + + server.use( + http.get(`${API_BASE_URL}/workplace/:id/review`, async () => + HttpResponse.json({ + content: [review], + page: 0, + size: 10, + totalElements: 1, + totalPages: 1, + hasNext: false, + hasPrevious: false, + }), + ), + ); + + let toggled = false; + server.use( + http.post(`${API_BASE_URL}/workplace/:workplaceId/review/:reviewId/helpful`, async () => { + toggled = !toggled; + return HttpResponse.json({ + ...review, + helpfulCount: toggled ? 2 : 1, + helpfulByUser: toggled, + }); + }), + ); + + renderPage(); + const user = userEvent.setup(); + + const button = await screen.findByRole('button', { name: /reviews.helpful \(1\)/i }); + + await user.click(button); + await waitFor(() => + expect(screen.getByRole('button', { name: /reviews.helpful \(2\)/i })).toBeInTheDocument(), + ); + + await user.click(screen.getByRole('button', { name: /reviews.helpful \(2\)/i })); + await waitFor(() => + expect(screen.getByRole('button', { name: /reviews.helpful \(1\)/i })).toBeInTheDocument(), + ); + }); + + it('rolls back helpful count on error', async () => { + const review = { + id: 401, + workplaceId: 1, + userId: 8, + username: 'user8', + title: 'Toggle error', + content: 'Should rollback', + anonymous: false, + helpfulCount: 3, + helpfulByUser: false, + overallRating: 4, + ethicalPolicyRatings: {}, + createdAt: '2024-03-01', + updatedAt: '2024-03-01', + }; + + server.use( + http.get(`${API_BASE_URL}/workplace/:id/review`, async () => + HttpResponse.json({ + content: [review], + page: 0, + size: 10, + totalElements: 1, + totalPages: 1, + hasNext: false, + hasPrevious: false, + }), + ), + http.post(`${API_BASE_URL}/workplace/:workplaceId/review/:reviewId/helpful`, async () => + new HttpResponse(null, { status: 500 }), + ), + ); + + renderPage(); + const user = userEvent.setup(); + + const button = await screen.findByRole('button', { name: /reviews.helpful \(3\)/i }); + await user.click(button); + + // Optimistic update will change text briefly; final state should roll back to original count + await waitFor(() => + expect(screen.getByRole('button', { name: /reviews.helpful \(3\)/i })).toBeInTheDocument(), + ); + }); }); diff --git a/apps/jobboard-frontend/src/hooks/__tests__/useReviewHelpful.test.tsx b/apps/jobboard-frontend/src/hooks/__tests__/useReviewHelpful.test.tsx new file mode 100644 index 00000000..466d1a64 --- /dev/null +++ b/apps/jobboard-frontend/src/hooks/__tests__/useReviewHelpful.test.tsx @@ -0,0 +1,130 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useReviewHelpful } from '../useReviewHelpful'; +import type { ReviewResponse } from '@/types/workplace.types'; + +const mockMarkReviewHelpful = vi.fn(); + +vi.mock('@/services/reviews.service', () => ({ + markReviewHelpful: (...args: unknown[]) => mockMarkReviewHelpful(...args), +})); + +vi.mock('react-toastify', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const baseReview: ReviewResponse = { + id: 1, + workplaceId: 10, + userId: 20, + anonymous: false, + helpfulCount: 0, + overallRating: 4, + ethicalPolicyRatings: {}, + createdAt: '2025-12-05T17:52:06.771Z', + updatedAt: '2025-12-05T17:52:06.771Z', +}; + +describe('useReviewHelpful', () => { + beforeEach(() => { + mockMarkReviewHelpful.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('initializes from provided count and helpfulByUser', () => { + const { result } = renderHook(() => + useReviewHelpful({ + workplaceId: 10, + reviewId: 1, + initialHelpfulCount: 5, + initialUserVoted: true, + }), + ); + + expect(result.current.helpfulCount).toBe(5); + expect(result.current.userVoted).toBe(true); + }); + + it('marks helpful and syncs response state', async () => { + mockMarkReviewHelpful.mockResolvedValue({ + ...baseReview, + helpfulCount: 6, + helpfulByUser: true, + }); + + const { result } = renderHook(() => + useReviewHelpful({ + workplaceId: 10, + reviewId: 1, + initialHelpfulCount: 5, + initialUserVoted: false, + }), + ); + + await act(async () => { + await result.current.toggleHelpful(); + }); + + expect(mockMarkReviewHelpful).toHaveBeenCalledWith(10, 1); + expect(result.current.helpfulCount).toBe(6); + expect(result.current.userVoted).toBe(true); + }); + + it('removes helpful when user clicks again', async () => { + mockMarkReviewHelpful.mockResolvedValue({ + ...baseReview, + helpfulCount: 4, + helpfulByUser: false, + }); + + const { result } = renderHook(() => + useReviewHelpful({ + workplaceId: 10, + reviewId: 1, + initialHelpfulCount: 5, + initialUserVoted: true, + }), + ); + + await act(async () => { + await result.current.toggleHelpful(); + }); + + expect(mockMarkReviewHelpful).toHaveBeenCalledWith(10, 1); + expect(result.current.helpfulCount).toBe(4); + expect(result.current.userVoted).toBe(false); + }); + + it('reverts optimistic update on error', async () => { + mockMarkReviewHelpful.mockRejectedValue(new Error('network')); + + const { result } = renderHook(() => + useReviewHelpful({ + workplaceId: 10, + reviewId: 1, + initialHelpfulCount: 2, + initialUserVoted: false, + }), + ); + + await act(async () => { + await result.current.toggleHelpful(); + }); + + expect(result.current.helpfulCount).toBe(2); + expect(result.current.userVoted).toBe(false); + }); +}); + From 602bf141e744ae49e1abd98dce1ed5e073db6a50 Mon Sep 17 00:00:00 2001 From: ykaydogdu Date: Fri, 5 Dec 2025 22:08:10 +0300 Subject: [PATCH 007/206] feat: Refactor module imports and enhance Vite configuration for better path resolution and chunking --- apps/jobboard-frontend/src/main.tsx | 8 +- apps/jobboard-frontend/src/router.tsx | 259 +---------------------- apps/jobboard-frontend/tsconfig.app.json | 4 +- apps/jobboard-frontend/tsconfig.json | 4 +- apps/jobboard-frontend/vite.config.ts | 22 ++ 5 files changed, 37 insertions(+), 260 deletions(-) diff --git a/apps/jobboard-frontend/src/main.tsx b/apps/jobboard-frontend/src/main.tsx index 2b0553f6..d5b398ac 100644 --- a/apps/jobboard-frontend/src/main.tsx +++ b/apps/jobboard-frontend/src/main.tsx @@ -2,10 +2,10 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { Router } from './router.tsx'; import './index.css'; -import './lib/i18n'; -import { ThemeProvider } from './providers/ThemeProvider.tsx'; -import { AuthProvider } from './contexts/AuthContext'; -import { I18nProvider } from './providers/I18nProvider'; +import '@shared/lib/i18n'; +import { ThemeProvider } from '@shared/providers/ThemeProvider.tsx'; +import { AuthProvider } from '@shared/contexts/AuthContext'; +import { I18nProvider } from '@shared/providers/I18nProvider'; createRoot(document.getElementById('root')!).render( diff --git a/apps/jobboard-frontend/src/router.tsx b/apps/jobboard-frontend/src/router.tsx index 05a8cb67..2b5b8709 100644 --- a/apps/jobboard-frontend/src/router.tsx +++ b/apps/jobboard-frontend/src/router.tsx @@ -1,265 +1,16 @@ import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { Suspense } from 'react'; -import RootLayout from './layouts/RootLayout'; -import ErrorBoundary from './components/ErrorBoundary'; -import CenteredLoader from './components/CenteredLoader'; -import HomePage from './pages/HomePage'; -import RegisterPage from './pages/RegisterPage'; -import LoginPage from './pages/LoginPage'; -import EmailVerificationPage from './pages/EmailVerificationPage'; -import ForgotPasswordPage from './pages/ForgotPasswordPage'; -import ResetPasswordPage from './pages/ResetPasswordPage'; -import VerifyOtpPage from './pages/VerifyOtpPage'; -import ProfilePage from './pages/ProfilePage'; -import JobsPage from './pages/JobsPage'; -import NonProfitJobsPage from './pages/NonProfitJobsPage'; -import JobDetailPage from './pages/JobDetailPage'; -import NonProfitJobDetailPage from './pages/NonProfitJobDetailPage'; -import JobApplicationPage from './pages/JobApplicationPage'; -import NonProfitJobApplicationPage from './pages/NonProfitJobApplicationPage'; -import MyApplicationsPage from './pages/MyApplicationsPage'; -import JobApplicationReviewPage from './pages/JobApplicationReviewPage'; -import EmployerDashboardPage from './pages/EmployerDashboardPage'; -import EmployerJobPostDetailsPage from './pages/EmployerJobPostDetailsPage'; -import EmployerEditJobPostPage from './pages/EmployerEditJobPostPage'; -import ProtectedRoute from './components/ProtectedRoute'; -import ForumPage from './pages/ForumPage'; -import MentorshipPage from './pages/MentorshipPage'; -import MentorProfilePage from './pages/MentorProfilePage'; -import MyMentorshipsPage from './pages/MyMentorshipsPage'; -import CreateMentorProfilePage from './pages/CreateMentorProfilePage'; -import MentorRequestsPage from './pages/MentorRequestsPage'; -import WorkplaceProfilePage from './pages/WorkplaceProfilePage'; -import EmployerWorkplacesPage from './pages/EmployerWorkplacesPage'; -import ManageEmployerRequestsPage from './pages/ManageEmployerRequestsPage'; -import WorkplaceSettingsPage from './pages/WorkplaceSettingsPage'; -import WorkplacesPage from './pages/WorkplacesPage'; -import ChatPage from './pages/ChatPage'; -import PublicProfilePage from './pages/PublicProfilePage'; -import ResumeReviewPage from './pages/ResumeReviewPage'; +import { featureRoutes } from './modules/routes'; +import RootLayout from '@shared/layouts/RootLayout'; +import ErrorBoundary from '@shared/components/common/ErrorBoundary'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; const router = createBrowserRouter([ { path: '/', element: , errorElement: , - children: [ - { - index: true, - element: , - loader: async () => { - return { message: 'Welcome to the Job Platform' }; - }, - }, - { - path: 'jobs', - element: , - }, - { - path: 'nonprofit-jobs', - element: , - }, - { - path: 'nonprofit-jobs/:id', - element: , - }, - { - path: 'jobs/:id', - element: , - }, - { - path: 'workplace/:id', - element: , - }, - { - path: 'workplaces', - element: , - }, - { - path: 'jobs/:id/apply', - element: ( - - - - ), - }, - { - path: 'nonprofit-jobs/:id/apply', - element: ( - - - - ), - }, - { - path: 'applications', - element: ( - - - - ), - }, - { - path: 'employer/dashboard', - element: ( - - - - ), - }, - { - path: 'employer/workplaces', - element: ( - - - - ), - }, - { - path: 'employer/workplace/:workplaceId/requests', - element: ( - - - - ), - }, - { - path: 'employer/workplace/:workplaceId/settings', - element: ( - - - - ), - }, - { - path: 'employer/jobs/:jobId', - element: ( - - - - ), - }, - { - path: 'employer/jobs/:jobId/edit', - element: ( - - - - ), - }, - { - path: 'employer/jobs/:jobId/applications/:applicationId', - element: ( - - - - ), - }, - { - path: 'mentorship', - element: , - }, - { - path: 'mentorship/mentor/create', - element: ( - - - - ), - }, - { - path: 'mentorship/mentor/edit', - element: ( - - - - ), - }, - { - path: 'mentorship/:id', - element: ( - - - - ), - }, - { - path: 'my-mentorships', - element: ( - - - - ), - }, - { - path: 'resume-review/:resumeReviewId', - element: ( - - - - ), - }, - { - path: 'mentor/requests', - element: ( - - - - ), - }, - { - path: 'chat', - element: ( - - - - ), - }, - { - path: 'forum', - element: , - }, - { - path: 'register', - element: , - }, - { - path: 'login', - element: , - }, - { - path: 'verify-email', - element: , - }, - { - path: 'forgot-password', - element: , - }, - { - path: 'reset-password', - element: , - }, - { - path: 'verify-otp', - element: , - }, - { - path: 'api/auth/reset-password', - element: , - }, - { - path: 'profile', - element: ( - - ), - }, - { - path: 'profile/:userId', - element: ( - - ), - }, - ], + children: featureRoutes, }, ]); diff --git a/apps/jobboard-frontend/tsconfig.app.json b/apps/jobboard-frontend/tsconfig.app.json index 2051ddfd..c89f3d6f 100644 --- a/apps/jobboard-frontend/tsconfig.app.json +++ b/apps/jobboard-frontend/tsconfig.app.json @@ -27,7 +27,9 @@ /* Tailwind */ "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@modules/*": ["./src/modules/*"], + "@shared/*": ["./src/modules/shared/*"] } }, "include": ["src"] diff --git a/apps/jobboard-frontend/tsconfig.json b/apps/jobboard-frontend/tsconfig.json index 2b78387c..9218a7bc 100644 --- a/apps/jobboard-frontend/tsconfig.json +++ b/apps/jobboard-frontend/tsconfig.json @@ -4,7 +4,9 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@modules/*": ["./src/modules/*"], + "@shared/*": ["./src/modules/shared/*"] } } } diff --git a/apps/jobboard-frontend/vite.config.ts b/apps/jobboard-frontend/vite.config.ts index 56442ec4..f1e2df6e 100644 --- a/apps/jobboard-frontend/vite.config.ts +++ b/apps/jobboard-frontend/vite.config.ts @@ -10,9 +10,31 @@ export default defineConfig({ define: { global: 'globalThis', }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('src/modules/jobs')) return 'jobs'; + if (id.includes('src/modules/mentorship')) return 'mentorship'; + if (id.includes('src/modules/forum')) return 'forum'; + if (id.includes('src/modules/workplace')) return 'workplace'; + if (id.includes('src/modules/profile')) return 'profile'; + if (id.includes('src/modules/resumeReview')) return 'resume-review'; + if (id.includes('src/modules/chat')) return 'chat'; + if (id.includes('src/modules/applications')) return 'applications'; + if (id.includes('src/modules/volunteering')) return 'volunteering'; + if (id.includes('src/modules/auth')) return 'auth'; + if (id.includes('src/modules/home')) return 'home'; + return undefined; + }, + }, + }, + }, resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@modules': path.resolve(__dirname, './src/modules'), + '@shared': path.resolve(__dirname, './src/modules/shared'), }, }, test: { From 2b1e07f1faade9fc3d714f67845895e65a42b65d Mon Sep 17 00:00:00 2001 From: ykaydogdu Date: Fri, 5 Dec 2025 22:09:06 +0300 Subject: [PATCH 008/206] refactor: move services to new modules --- .../services/applications.service.ts | 268 +++++++++--------- .../chat}/services/chat.service.ts | 6 +- .../employer}/services/dashboard.service.ts | 2 +- .../employer}/services/employer.service.ts | 4 +- .../jobs}/services/jobs.service.ts | 174 ++++++------ .../services/mentorship.service.ts | 4 +- .../profile}/services/profile.service.ts | 6 +- .../resumeReview}/services/reviews.service.ts | 4 +- .../services/workplace-report.service.ts | 4 +- .../workplace}/services/workplace.service.ts | 4 +- 10 files changed, 238 insertions(+), 238 deletions(-) rename apps/jobboard-frontend/src/{ => modules/applications}/services/applications.service.ts (94%) rename apps/jobboard-frontend/src/{ => modules/chat}/services/chat.service.ts (97%) rename apps/jobboard-frontend/src/{ => modules/employer}/services/dashboard.service.ts (96%) rename apps/jobboard-frontend/src/{ => modules/employer}/services/employer.service.ts (97%) rename apps/jobboard-frontend/src/{ => modules/jobs}/services/jobs.service.ts (94%) rename apps/jobboard-frontend/src/{ => modules/mentorship}/services/mentorship.service.ts (98%) rename apps/jobboard-frontend/src/{ => modules/profile}/services/profile.service.ts (98%) rename apps/jobboard-frontend/src/{ => modules/resumeReview}/services/reviews.service.ts (98%) rename apps/jobboard-frontend/src/{ => modules/workplace}/services/workplace-report.service.ts (89%) rename apps/jobboard-frontend/src/{ => modules/workplace}/services/workplace.service.ts (98%) diff --git a/apps/jobboard-frontend/src/services/applications.service.ts b/apps/jobboard-frontend/src/modules/applications/services/applications.service.ts similarity index 94% rename from apps/jobboard-frontend/src/services/applications.service.ts rename to apps/jobboard-frontend/src/modules/applications/services/applications.service.ts index 0c2a6a7b..7bbcdc38 100644 --- a/apps/jobboard-frontend/src/services/applications.service.ts +++ b/apps/jobboard-frontend/src/modules/applications/services/applications.service.ts @@ -1,134 +1,134 @@ -import { api } from '@/lib/api-client'; -import type { - JobApplicationResponse, - CreateJobApplicationRequest, - UpdateJobApplicationRequest, - ApplicationsFilterParams, - CvUploadResponse, -} from '@/types/api.types'; - -/** - * Applications Service - * Handles all API calls related to job applications - */ - -/** - * Get filtered list of applications - * GET /api/applications - */ -export async function getApplications( - filters?: ApplicationsFilterParams -): Promise { - const params = new URLSearchParams(); - - if (filters) { - if (filters.jobSeekerId !== undefined) { - params.append('jobSeekerId', filters.jobSeekerId.toString()); - } - if (filters.jobPostId !== undefined) { - params.append('jobPostId', filters.jobPostId.toString()); - } - } - - const queryString = params.toString(); - const url = queryString ? `/applications?${queryString}` : '/applications'; - - const response = await api.get(url); - return response.data; -} - -/** - * Get applications by job seeker ID - * GET /api/applications/job-seeker/{jobSeekerId} - */ -export async function getApplicationsByJobSeeker(jobSeekerId: number): Promise { - const response = await api.get(`/applications/job-seeker/${jobSeekerId}`); - return response.data; -} - -/** - * Get a single application by ID - * GET /api/applications/{id} - */ -export async function getApplicationById(id: number): Promise { - const response = await api.get(`/applications/${id}`); - return response.data; -} - -/** - * Create a new job application - * POST /api/applications - */ -export async function createApplication( - data: CreateJobApplicationRequest -): Promise { - const response = await api.post('/applications', data); - return response.data; -} - -/** - * Approve an application (with optional feedback) - * PUT /api/applications/{id}/approve - */ -export async function approveApplication( - id: number, - feedback?: string -): Promise { - const data: UpdateJobApplicationRequest = feedback ? { feedback } : {}; - const response = await api.put(`/applications/${id}/approve`, data); - return response.data; -} - -/** - * Reject an application (with optional feedback) - * PUT /api/applications/{id}/reject - */ -export async function rejectApplication( - id: number, - feedback?: string -): Promise { - const data: UpdateJobApplicationRequest = feedback ? { feedback } : {}; - const response = await api.put(`/applications/${id}/reject`, data); - return response.data; -} - -/** - * Delete an application - * DELETE /api/applications/{id} - */ -export async function deleteApplication(id: number): Promise { - await api.delete(`/applications/${id}`); -} - -/** - * Upload CV for an application - * POST /api/applications/{id}/cv - */ -export async function uploadCv(id: number, file: File): Promise { - const formData = new FormData(); - formData.append('file', file); - - const response = await api.post(`/applications/${id}/cv`, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - return response.data; -} - -/** - * Get CV URL for an application - * GET /api/applications/{id}/cv - */ -export async function getCvUrl(id: number): Promise { - const response = await api.get(`/applications/${id}/cv`); - return response.data; -} - -/** - * Delete CV from an application - * DELETE /api/applications/{id}/cv - */ -export async function deleteCv(id: number): Promise { - await api.delete(`/applications/${id}/cv`); -} +import { api } from '@shared/lib/api-client'; +import type { + JobApplicationResponse, + CreateJobApplicationRequest, + UpdateJobApplicationRequest, + ApplicationsFilterParams, + CvUploadResponse, +} from '@shared/types/api.types'; + +/** + * Applications Service + * Handles all API calls related to job applications + */ + +/** + * Get filtered list of applications + * GET /api/applications + */ +export async function getApplications( + filters?: ApplicationsFilterParams +): Promise { + const params = new URLSearchParams(); + + if (filters) { + if (filters.jobSeekerId !== undefined) { + params.append('jobSeekerId', filters.jobSeekerId.toString()); + } + if (filters.jobPostId !== undefined) { + params.append('jobPostId', filters.jobPostId.toString()); + } + } + + const queryString = params.toString(); + const url = queryString ? `/applications?${queryString}` : '/applications'; + + const response = await api.get(url); + return response.data; +} + +/** + * Get applications by job seeker ID + * GET /api/applications/job-seeker/{jobSeekerId} + */ +export async function getApplicationsByJobSeeker(jobSeekerId: number): Promise { + const response = await api.get(`/applications/job-seeker/${jobSeekerId}`); + return response.data; +} + +/** + * Get a single application by ID + * GET /api/applications/{id} + */ +export async function getApplicationById(id: number): Promise { + const response = await api.get(`/applications/${id}`); + return response.data; +} + +/** + * Create a new job application + * POST /api/applications + */ +export async function createApplication( + data: CreateJobApplicationRequest +): Promise { + const response = await api.post('/applications', data); + return response.data; +} + +/** + * Approve an application (with optional feedback) + * PUT /api/applications/{id}/approve + */ +export async function approveApplication( + id: number, + feedback?: string +): Promise { + const data: UpdateJobApplicationRequest = feedback ? { feedback } : {}; + const response = await api.put(`/applications/${id}/approve`, data); + return response.data; +} + +/** + * Reject an application (with optional feedback) + * PUT /api/applications/{id}/reject + */ +export async function rejectApplication( + id: number, + feedback?: string +): Promise { + const data: UpdateJobApplicationRequest = feedback ? { feedback } : {}; + const response = await api.put(`/applications/${id}/reject`, data); + return response.data; +} + +/** + * Delete an application + * DELETE /api/applications/{id} + */ +export async function deleteApplication(id: number): Promise { + await api.delete(`/applications/${id}`); +} + +/** + * Upload CV for an application + * POST /api/applications/{id}/cv + */ +export async function uploadCv(id: number, file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + const response = await api.post(`/applications/${id}/cv`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; +} + +/** + * Get CV URL for an application + * GET /api/applications/{id}/cv + */ +export async function getCvUrl(id: number): Promise { + const response = await api.get(`/applications/${id}/cv`); + return response.data; +} + +/** + * Delete CV from an application + * DELETE /api/applications/{id}/cv + */ +export async function deleteCv(id: number): Promise { + await api.delete(`/applications/${id}/cv`); +} diff --git a/apps/jobboard-frontend/src/services/chat.service.ts b/apps/jobboard-frontend/src/modules/chat/services/chat.service.ts similarity index 97% rename from apps/jobboard-frontend/src/services/chat.service.ts rename to apps/jobboard-frontend/src/modules/chat/services/chat.service.ts index be4f805c..f85d3a57 100644 --- a/apps/jobboard-frontend/src/services/chat.service.ts +++ b/apps/jobboard-frontend/src/modules/chat/services/chat.service.ts @@ -1,6 +1,6 @@ -import { api } from '@/lib/api-client'; -import type { ChatMessageDTO } from '@/types/api.types'; -import type { ChatMessage } from '@/types/chat'; +import { api } from '@shared/lib/api-client'; +import type { ChatMessageDTO } from '@shared/types/api.types'; +import type { ChatMessage } from '@shared/types/chat'; import SockJS from 'sockjs-client'; import { Client, type StompSubscription } from '@stomp/stompjs'; diff --git a/apps/jobboard-frontend/src/services/dashboard.service.ts b/apps/jobboard-frontend/src/modules/employer/services/dashboard.service.ts similarity index 96% rename from apps/jobboard-frontend/src/services/dashboard.service.ts rename to apps/jobboard-frontend/src/modules/employer/services/dashboard.service.ts index 81af83a9..c53ccf07 100644 --- a/apps/jobboard-frontend/src/services/dashboard.service.ts +++ b/apps/jobboard-frontend/src/modules/employer/services/dashboard.service.ts @@ -1,4 +1,4 @@ -import { api } from '@/lib/api-client'; +import { api } from '@shared/lib/api-client'; import type { AxiosRequestConfig } from 'axios'; /** diff --git a/apps/jobboard-frontend/src/services/employer.service.ts b/apps/jobboard-frontend/src/modules/employer/services/employer.service.ts similarity index 97% rename from apps/jobboard-frontend/src/services/employer.service.ts rename to apps/jobboard-frontend/src/modules/employer/services/employer.service.ts index 137b5831..1bb5a4c1 100644 --- a/apps/jobboard-frontend/src/services/employer.service.ts +++ b/apps/jobboard-frontend/src/modules/employer/services/employer.service.ts @@ -3,7 +3,7 @@ * Handles employer workflow operations (join requests, employer management) */ -import { api } from '@/lib/api-client'; +import { api } from '@shared/lib/api-client'; import type { EmployerWorkplaceBrief, EmployerListItem, @@ -13,7 +13,7 @@ import type { PaginatedEmployerRequestResponse, EmployerRequestListParams, ApiMessage, -} from '@/types/workplace.types'; +} from '@shared/types/workplace.types'; const BASE_PATH = '/workplace'; diff --git a/apps/jobboard-frontend/src/services/jobs.service.ts b/apps/jobboard-frontend/src/modules/jobs/services/jobs.service.ts similarity index 94% rename from apps/jobboard-frontend/src/services/jobs.service.ts rename to apps/jobboard-frontend/src/modules/jobs/services/jobs.service.ts index a66c78e5..032aa060 100644 --- a/apps/jobboard-frontend/src/services/jobs.service.ts +++ b/apps/jobboard-frontend/src/modules/jobs/services/jobs.service.ts @@ -1,87 +1,87 @@ -import { api } from '@/lib/api-client'; -import type { - JobPostResponse, - CreateJobPostRequest, - UpdateJobPostRequest, - JobsFilterParams, -} from '@/types/api.types'; -import type { AxiosRequestConfig } from 'axios'; - -/** - * Jobs Service - * Handles all API calls related to job posts - */ - -/** - * Get filtered list of jobs - * GET /api/jobs - */ -export async function getJobs(filters?: JobsFilterParams): Promise { - const params = new URLSearchParams(); - - if (filters) { - if (filters.title) params.append('title', filters.title); - if (filters.companyName) params.append('companyName', filters.companyName); - if (filters.ethicalTags) { - filters.ethicalTags.forEach((tag) => params.append('ethicalTags', tag)); - } - if (filters.minSalary !== undefined) params.append('minSalary', filters.minSalary.toString()); - if (filters.maxSalary !== undefined) params.append('maxSalary', filters.maxSalary.toString()); - if (filters.isRemote !== undefined) params.append('isRemote', filters.isRemote.toString()); - if (filters.inclusiveOpportunity !== undefined) { - params.append('inclusiveOpportunity', filters.inclusiveOpportunity.toString()); - } - } - - const queryString = params.toString(); - const url = queryString ? `/jobs?${queryString}` : '/jobs'; - - const response = await api.get(url, { - skipAuthRedirect: true, - } as unknown as AxiosRequestConfig); - return response.data as JobPostResponse[]; -} - -/** - * Get a single job post by ID - * GET /api/jobs/{id} - */ -export async function getJobById(id: number): Promise { - const response = await api.get(`/jobs/${id}`); - return response.data; -} - -/** - * Get all jobs posted by a specific employer - * GET /api/jobs/employer/{employerId} - */ -export async function getJobsByEmployer(employerId: number): Promise { - const response = await api.get(`/jobs/employer/${employerId}`); - return response.data; -} - -/** - * Create a new job post - * POST /api/jobs - */ -export async function createJob(data: CreateJobPostRequest): Promise { - const response = await api.post('/jobs', data); - return response.data; -} - -/** - * Update an existing job post - * PUT /api/jobs/{id} - */ -export async function updateJob(id: number, data: UpdateJobPostRequest): Promise { - const response = await api.put(`/jobs/${id}`, data); - return response.data; -} - -/** - * Delete a job post - * DELETE /api/jobs/{id} - */ -export async function deleteJob(id: number): Promise { - await api.delete(`/jobs/${id}`); -} +import { api } from '@shared/lib/api-client'; +import type { + JobPostResponse, + CreateJobPostRequest, + UpdateJobPostRequest, + JobsFilterParams, +} from '@shared/types/api.types'; +import type { AxiosRequestConfig } from 'axios'; + +/** + * Jobs Service + * Handles all API calls related to job posts + */ + +/** + * Get filtered list of jobs + * GET /api/jobs + */ +export async function getJobs(filters?: JobsFilterParams): Promise { + const params = new URLSearchParams(); + + if (filters) { + if (filters.title) params.append('title', filters.title); + if (filters.companyName) params.append('companyName', filters.companyName); + if (filters.ethicalTags) { + filters.ethicalTags.forEach((tag) => params.append('ethicalTags', tag)); + } + if (filters.minSalary !== undefined) params.append('minSalary', filters.minSalary.toString()); + if (filters.maxSalary !== undefined) params.append('maxSalary', filters.maxSalary.toString()); + if (filters.isRemote !== undefined) params.append('isRemote', filters.isRemote.toString()); + if (filters.inclusiveOpportunity !== undefined) { + params.append('inclusiveOpportunity', filters.inclusiveOpportunity.toString()); + } + } + + const queryString = params.toString(); + const url = queryString ? `/jobs?${queryString}` : '/jobs'; + + const response = await api.get(url, { + skipAuthRedirect: true, + } as unknown as AxiosRequestConfig); + return response.data as JobPostResponse[]; +} + +/** + * Get a single job post by ID + * GET /api/jobs/{id} + */ +export async function getJobById(id: number): Promise { + const response = await api.get(`/jobs/${id}`); + return response.data; +} + +/** + * Get all jobs posted by a specific employer + * GET /api/jobs/employer/{employerId} + */ +export async function getJobsByEmployer(employerId: number): Promise { + const response = await api.get(`/jobs/employer/${employerId}`); + return response.data; +} + +/** + * Create a new job post + * POST /api/jobs + */ +export async function createJob(data: CreateJobPostRequest): Promise { + const response = await api.post('/jobs', data); + return response.data; +} + +/** + * Update an existing job post + * PUT /api/jobs/{id} + */ +export async function updateJob(id: number, data: UpdateJobPostRequest): Promise { + const response = await api.put(`/jobs/${id}`, data); + return response.data; +} + +/** + * Delete a job post + * DELETE /api/jobs/{id} + */ +export async function deleteJob(id: number): Promise { + await api.delete(`/jobs/${id}`); +} diff --git a/apps/jobboard-frontend/src/services/mentorship.service.ts b/apps/jobboard-frontend/src/modules/mentorship/services/mentorship.service.ts similarity index 98% rename from apps/jobboard-frontend/src/services/mentorship.service.ts rename to apps/jobboard-frontend/src/modules/mentorship/services/mentorship.service.ts index 9f2345a6..c57a1f37 100644 --- a/apps/jobboard-frontend/src/services/mentorship.service.ts +++ b/apps/jobboard-frontend/src/modules/mentorship/services/mentorship.service.ts @@ -1,4 +1,4 @@ -import { api } from '@/lib/api-client'; +import { api } from '@shared/lib/api-client'; import type { CreateMentorProfileDTO, UpdateMentorProfileDTO, @@ -11,7 +11,7 @@ import type { CreateRatingDTO, ResumeReviewDTO, ResumeFileResponseDTO, -} from '@/types/api.types'; +} from '@shared/types/api.types'; /** * Mentorship Service diff --git a/apps/jobboard-frontend/src/services/profile.service.ts b/apps/jobboard-frontend/src/modules/profile/services/profile.service.ts similarity index 98% rename from apps/jobboard-frontend/src/services/profile.service.ts rename to apps/jobboard-frontend/src/modules/profile/services/profile.service.ts index 1eb7b664..57c7d0ec 100644 --- a/apps/jobboard-frontend/src/services/profile.service.ts +++ b/apps/jobboard-frontend/src/modules/profile/services/profile.service.ts @@ -1,5 +1,5 @@ -import { apiClient } from '@/lib/api-client'; -import type { Profile, PublicProfile } from '@/types/profile.types'; +import { apiClient } from '@shared/lib/api-client'; +import type { Profile, PublicProfile } from '@shared/types/profile.types'; /** * Profile API Service @@ -301,4 +301,4 @@ export const profileService = { throw new Error('Failed to delete profile data. Please try again.'); } }, -}; \ No newline at end of file +}; diff --git a/apps/jobboard-frontend/src/services/reviews.service.ts b/apps/jobboard-frontend/src/modules/resumeReview/services/reviews.service.ts similarity index 98% rename from apps/jobboard-frontend/src/services/reviews.service.ts rename to apps/jobboard-frontend/src/modules/resumeReview/services/reviews.service.ts index 930182f5..ad15749a 100644 --- a/apps/jobboard-frontend/src/services/reviews.service.ts +++ b/apps/jobboard-frontend/src/modules/resumeReview/services/reviews.service.ts @@ -3,7 +3,7 @@ * Handles workplace review and reply operations */ -import { api } from '@/lib/api-client'; +import { api } from '@shared/lib/api-client'; import type { ReviewCreateRequest, ReviewUpdateRequest, @@ -15,7 +15,7 @@ import type { ReviewListParams, ReviewReportCreate, ApiMessage, -} from '@/types/workplace.types'; +} from '@shared/types/workplace.types'; const BASE_PATH = '/workplace'; diff --git a/apps/jobboard-frontend/src/services/workplace-report.service.ts b/apps/jobboard-frontend/src/modules/workplace/services/workplace-report.service.ts similarity index 89% rename from apps/jobboard-frontend/src/services/workplace-report.service.ts rename to apps/jobboard-frontend/src/modules/workplace/services/workplace-report.service.ts index 1d970dc7..4aee9d1f 100644 --- a/apps/jobboard-frontend/src/services/workplace-report.service.ts +++ b/apps/jobboard-frontend/src/modules/workplace/services/workplace-report.service.ts @@ -1,5 +1,5 @@ -import { apiClient } from '@/lib/api-client'; -import type { ReportReasonType } from '@/components/report/ReportModal'; +import { apiClient } from '@shared/lib/api-client'; +import type { ReportReasonType } from '@modules/workplace/components/report/ReportModal'; export async function reportWorkplace(workplaceId: number, message: string, reason: ReportReasonType = 'OTHER') { return apiClient.post(`/workplace/${workplaceId}/report`, { diff --git a/apps/jobboard-frontend/src/services/workplace.service.ts b/apps/jobboard-frontend/src/modules/workplace/services/workplace.service.ts similarity index 98% rename from apps/jobboard-frontend/src/services/workplace.service.ts rename to apps/jobboard-frontend/src/modules/workplace/services/workplace.service.ts index 59bf2c67..90fadd1f 100644 --- a/apps/jobboard-frontend/src/services/workplace.service.ts +++ b/apps/jobboard-frontend/src/modules/workplace/services/workplace.service.ts @@ -3,7 +3,7 @@ * Handles all workplace-related API calls */ -import { api } from '@/lib/api-client'; +import { api } from '@shared/lib/api-client'; import type { WorkplaceCreateRequest, WorkplaceUpdateRequest, @@ -13,7 +13,7 @@ import type { PaginatedWorkplaceResponse, WorkplaceListParams, ApiMessage, -} from '@/types/workplace.types'; +} from '@shared/types/workplace.types'; const BASE_PATH = '/workplace'; From 61513e466cce0fe9a1a54f9d4258f725a7c3de1e Mon Sep 17 00:00:00 2001 From: ykaydogdu Date: Fri, 5 Dec 2025 22:10:04 +0300 Subject: [PATCH 009/206] refactor: move pages to new modules --- .../pages/JobApplicationPage.tsx | 18 +- .../pages/JobApplicationReviewPage.tsx | 14 +- .../pages/MyApplicationsPage.tsx | 906 +++---- .../auth}/pages/EmailVerificationPage.tsx | 6 +- .../auth}/pages/ForgotPasswordPage.tsx | 4 +- .../{ => modules/auth}/pages/LoginPage.tsx | 6 +- .../{ => modules/auth}/pages/RegisterPage.tsx | 4 +- .../auth}/pages/ResetPasswordPage.tsx | 4 +- .../auth}/pages/VerifyOtpPage.tsx | 6 +- .../src/{ => modules/chat}/pages/ChatPage.tsx | 22 +- .../employer}/pages/EmployerDashboardPage.tsx | 946 ++++---- .../pages/EmployerEditJobPostPage.tsx | 20 +- .../pages/EmployerJobPostDetailsPage.tsx | 510 ++-- .../{ => modules/forum}/pages/ForumPage.tsx | 2 +- .../src/{ => modules/home}/pages/HomePage.tsx | 57 +- .../jobs}/pages/JobDetailPage.tsx | 392 +-- .../src/{ => modules/jobs}/pages/JobsPage.tsx | 838 +++---- .../pages/CreateMentorProfilePage.tsx | 24 +- .../mentorship}/pages/MentorProfilePage.tsx | 30 +- .../mentorship}/pages/MentorRequestsPage.tsx | 24 +- .../mentorship}/pages/MentorshipPage.tsx | 25 +- .../pages/MentorshipRequestPage.tsx | 28 +- .../mentorship}/pages/MyMentorshipsPage.tsx | 32 +- .../profile}/pages/ProfilePage.tsx | 26 +- .../profile}/pages/PublicProfilePage.tsx | 20 +- .../resumeReview}/pages/ResumeReviewPage.tsx | 16 +- .../pages/NonProfitJobApplicationPage.tsx | 22 +- .../pages/NonProfitJobDetailPage.tsx | 18 +- .../volunteering}/pages/NonProfitJobsPage.tsx | 14 +- .../pages/EmployerWorkplacesPage.tsx | 26 +- .../pages/ManageEmployerRequestsPage.tsx | 22 +- .../workplace}/pages/WorkplaceProfilePage.tsx | 34 +- .../pages/WorkplaceSettingsPage.tsx | 28 +- .../workplace}/pages/WorkplacesPage.tsx | 20 +- .../pages/__tests__/auth/LoginPage.test.tsx | 102 - .../__tests__/auth/RegisterPage.test.tsx | 70 - .../__tests__/auth/ResetPasswordPage.test.tsx | 74 - .../jobs/EmployerDashboardPage.test.tsx | 649 ----- .../jobs/EmployerEditJobPostPage.test.tsx | 846 ------- .../jobs/EmployerJobPostDetailsPage.test.tsx | 744 ------ .../jobs/JobApplicationReviewPage.test.tsx | 880 ------- .../__tests__/profile/ProfilePage.test.tsx | 2138 ----------------- 42 files changed, 2105 insertions(+), 7562 deletions(-) rename apps/jobboard-frontend/src/{ => modules/applications}/pages/JobApplicationPage.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/applications}/pages/JobApplicationReviewPage.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/applications}/pages/MyApplicationsPage.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/auth}/pages/EmailVerificationPage.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/auth}/pages/ForgotPasswordPage.tsx (98%) rename apps/jobboard-frontend/src/{ => modules/auth}/pages/LoginPage.tsx (98%) rename apps/jobboard-frontend/src/{ => modules/auth}/pages/RegisterPage.tsx (99%) rename apps/jobboard-frontend/src/{ => modules/auth}/pages/ResetPasswordPage.tsx (98%) rename apps/jobboard-frontend/src/{ => modules/auth}/pages/VerifyOtpPage.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/chat}/pages/ChatPage.tsx (98%) rename apps/jobboard-frontend/src/{ => modules/employer}/pages/EmployerDashboardPage.tsx (93%) rename apps/jobboard-frontend/src/{ => modules/employer}/pages/EmployerEditJobPostPage.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/employer}/pages/EmployerJobPostDetailsPage.tsx (93%) rename apps/jobboard-frontend/src/{ => modules/forum}/pages/ForumPage.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/home}/pages/HomePage.tsx (88%) rename apps/jobboard-frontend/src/{ => modules/jobs}/pages/JobDetailPage.tsx (92%) rename apps/jobboard-frontend/src/{ => modules/jobs}/pages/JobsPage.tsx (93%) rename apps/jobboard-frontend/src/{ => modules/mentorship}/pages/CreateMentorProfilePage.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/mentorship}/pages/MentorProfilePage.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/mentorship}/pages/MentorRequestsPage.tsx (94%) rename apps/jobboard-frontend/src/{ => modules/mentorship}/pages/MentorshipPage.tsx (92%) rename apps/jobboard-frontend/src/{ => modules/mentorship}/pages/MentorshipRequestPage.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/mentorship}/pages/MyMentorshipsPage.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/profile}/pages/ProfilePage.tsx (94%) rename apps/jobboard-frontend/src/{ => modules/profile}/pages/PublicProfilePage.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/resumeReview}/pages/ResumeReviewPage.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/volunteering}/pages/NonProfitJobApplicationPage.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/volunteering}/pages/NonProfitJobDetailPage.tsx (93%) rename apps/jobboard-frontend/src/{ => modules/volunteering}/pages/NonProfitJobsPage.tsx (94%) rename apps/jobboard-frontend/src/{ => modules/workplace}/pages/EmployerWorkplacesPage.tsx (92%) rename apps/jobboard-frontend/src/{ => modules/workplace}/pages/ManageEmployerRequestsPage.tsx (92%) rename apps/jobboard-frontend/src/{ => modules/workplace}/pages/WorkplaceProfilePage.tsx (94%) rename apps/jobboard-frontend/src/{ => modules/workplace}/pages/WorkplaceSettingsPage.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/workplace}/pages/WorkplacesPage.tsx (94%) delete mode 100644 apps/jobboard-frontend/src/pages/__tests__/auth/LoginPage.test.tsx delete mode 100644 apps/jobboard-frontend/src/pages/__tests__/auth/RegisterPage.test.tsx delete mode 100644 apps/jobboard-frontend/src/pages/__tests__/auth/ResetPasswordPage.test.tsx delete mode 100644 apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerDashboardPage.test.tsx delete mode 100644 apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerEditJobPostPage.test.tsx delete mode 100644 apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerJobPostDetailsPage.test.tsx delete mode 100644 apps/jobboard-frontend/src/pages/__tests__/jobs/JobApplicationReviewPage.test.tsx delete mode 100644 apps/jobboard-frontend/src/pages/__tests__/profile/ProfilePage.test.tsx diff --git a/apps/jobboard-frontend/src/pages/JobApplicationPage.tsx b/apps/jobboard-frontend/src/modules/applications/pages/JobApplicationPage.tsx similarity index 96% rename from apps/jobboard-frontend/src/pages/JobApplicationPage.tsx rename to apps/jobboard-frontend/src/modules/applications/pages/JobApplicationPage.tsx index f93a64a1..22167bce 100644 --- a/apps/jobboard-frontend/src/pages/JobApplicationPage.tsx +++ b/apps/jobboard-frontend/src/modules/applications/pages/JobApplicationPage.tsx @@ -3,15 +3,15 @@ import { useParams, useNavigate, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { ChevronRight, Upload, X, FileText } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import CenteredLoader from '@/components/CenteredLoader'; -import { cn } from '@/lib/utils'; -import type { JobPostResponse } from '@/types/api.types'; -import { getJobById } from '@/services/jobs.service'; -import { createApplication, uploadCv, getApplicationsByJobSeeker } from '@/services/applications.service'; -import { useAuth } from '@/contexts/AuthContext'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Label } from '@shared/components/ui/label'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import { cn } from '@shared/lib/utils'; +import type { JobPostResponse } from '@shared/types/api.types'; +import { getJobById } from '@modules/jobs/services/jobs.service'; +import { createApplication, uploadCv, getApplicationsByJobSeeker } from '@modules/applications/services/applications.service'; +import { useAuth } from '@shared/contexts/AuthContext'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const ALLOWED_FILE_TYPES = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; diff --git a/apps/jobboard-frontend/src/pages/JobApplicationReviewPage.tsx b/apps/jobboard-frontend/src/modules/applications/pages/JobApplicationReviewPage.tsx similarity index 96% rename from apps/jobboard-frontend/src/pages/JobApplicationReviewPage.tsx rename to apps/jobboard-frontend/src/modules/applications/pages/JobApplicationReviewPage.tsx index 04f63c37..df24de84 100644 --- a/apps/jobboard-frontend/src/pages/JobApplicationReviewPage.tsx +++ b/apps/jobboard-frontend/src/modules/applications/pages/JobApplicationReviewPage.tsx @@ -2,13 +2,13 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Download, FileText } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Label } from '@/components/ui/label'; -import { getApplicationById, approveApplication, rejectApplication, getCvUrl } from '@/services/applications.service'; -import type { JobApplicationResponse } from '@/types/api.types'; -import CenteredLoader from '@/components/CenteredLoader'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Avatar, AvatarFallback } from '@shared/components/ui/avatar'; +import { Label } from '@shared/components/ui/label'; +import { getApplicationById, approveApplication, rejectApplication, getCvUrl } from '@modules/applications/services/applications.service'; +import type { JobApplicationResponse } from '@shared/types/api.types'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; import { useTranslation } from 'react-i18next'; diff --git a/apps/jobboard-frontend/src/pages/MyApplicationsPage.tsx b/apps/jobboard-frontend/src/modules/applications/pages/MyApplicationsPage.tsx similarity index 95% rename from apps/jobboard-frontend/src/pages/MyApplicationsPage.tsx rename to apps/jobboard-frontend/src/modules/applications/pages/MyApplicationsPage.tsx index 1b6370fd..04406fff 100644 --- a/apps/jobboard-frontend/src/pages/MyApplicationsPage.tsx +++ b/apps/jobboard-frontend/src/modules/applications/pages/MyApplicationsPage.tsx @@ -1,453 +1,453 @@ -import { useState, useEffect, useMemo } from 'react'; -import { Link } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { Briefcase, MapPin, Download, ExternalLink, Trash2, FileText, Heart } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import CenteredLoader from '@/components/CenteredLoader'; -import type { JobApplicationResponse, JobApplicationStatus } from '@/types/api.types'; -import { getApplicationsByJobSeeker, deleteApplication, getCvUrl } from '@/services/applications.service'; -import { getJobById } from '@/services/jobs.service'; -import { useAuth } from '@/contexts/AuthContext'; - -type FilterType = 'all' | 'PENDING' | 'APPROVED' | 'REJECTED'; - -export default function MyApplicationsPage() { - const { user } = useAuth(); - const { t, i18n } = useTranslation('common'); - const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; - const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; - - const [applications, setApplications] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [filter, setFilter] = useState('all'); - const [nonprofitJobs, setNonprofitJobs] = useState>(new Set()); - - // Detail modal state - const [selectedApplication, setSelectedApplication] = useState(null); - const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); - - // Withdraw modal state - const [applicationToWithdraw, setApplicationToWithdraw] = useState(null); - const [isWithdrawing, setIsWithdrawing] = useState(false); - - // Cache for job details to avoid repeated API calls - const jobDetailsCache = useMemo(() => new Map(), []); - - // Helper function to get the correct job detail route - const getJobDetailRoute = (application: JobApplicationResponse): string => { - const isNonprofit = nonprofitJobs.has(application.jobPostId); - return isNonprofit ? `/nonprofit-jobs/${application.jobPostId}` : `/jobs/${application.jobPostId}`; - }; - - // Function to check if a job is nonprofit (with caching) - const checkIfNonprofitJob = async (jobPostId: number): Promise => { - if (jobDetailsCache.has(jobPostId)) { - return jobDetailsCache.get(jobPostId); - } - - try { - const jobDetails = await getJobById(jobPostId); - const isNonprofit = !!jobDetails.nonProfit; - jobDetailsCache.set(jobPostId, isNonprofit); - return isNonprofit; - } catch (error) { - // If we can't fetch job details, default to regular job - console.warn(`Could not fetch details for job ${jobPostId}:`, error); - jobDetailsCache.set(jobPostId, false); - return false; - } - }; - - useEffect(() => { - const fetchApplications = async () => { - if (!user) return; - - try { - setIsLoading(true); - setError(null); - - const apps = await getApplicationsByJobSeeker(user.id); - - setApplications(apps); - - // Check which jobs are nonprofit jobs (batch processing for better performance) - const nonprofitJobIds = new Set(); - const uniqueJobIds = [...new Set(apps.map((app) => app.jobPostId))]; - - await Promise.all( - uniqueJobIds.map(async (jobPostId: number) => { - const isNonprofit = await checkIfNonprofitJob(jobPostId); - if (isNonprofit) { - nonprofitJobIds.add(jobPostId); - } - }) - ); - - setNonprofitJobs(nonprofitJobIds); - } catch (err) { - console.error('Error fetching applications:', err); - setError(t('myApplications.errors.loadFailed')); - } finally { - setIsLoading(false); - } - }; - - fetchApplications(); - }, [user, t, jobDetailsCache]); - - const filteredApplications = useMemo(() => { - if (filter === 'all') { - return applications; - } - return applications.filter((app) => app.status === filter); - }, [applications, filter]); - - const stats = useMemo(() => { - return { - total: applications.length, - pending: applications.filter((app) => app.status === 'PENDING').length, - approved: applications.filter((app) => app.status === 'APPROVED').length, - rejected: applications.filter((app) => app.status === 'REJECTED').length, - }; - }, [applications]); - - const handleWithdraw = async () => { - if (!applicationToWithdraw) return; - - try { - setIsWithdrawing(true); - await deleteApplication(applicationToWithdraw); - - // Remove from list - setApplications((prev) => prev.filter((app) => app.id !== applicationToWithdraw)); - - setApplicationToWithdraw(null); - } catch (err) { - console.error('Error withdrawing application:', err); - setError(t('myApplications.withdraw.error')); - } finally { - setIsWithdrawing(false); - } - }; - - const handleDownloadCV = async (applicationId: number) => { - try { - const cvUrl = await getCvUrl(applicationId); - // Open in new tab - window.open(cvUrl, '_blank'); - } catch (err) { - console.error('Error downloading CV:', err); - } - }; - - const formatDate = (isoDate: string) => { - const date = new Date(isoDate); - return date.toLocaleDateString(resolvedLanguage, { - year: 'numeric', - month: 'short', - day: 'numeric', - }); - }; - - const getStatusBadgeVariant = (status: JobApplicationStatus) => { - switch (status) { - case 'PENDING': - return 'default'; - case 'APPROVED': - return 'success'; - case 'REJECTED': - return 'destructive'; - default: - return 'default'; - } - }; - - if (isLoading) { - return ; - } - - if (error && applications.length === 0) { - return ( -
-

{error}

- -
- ); - } - - return ( -
- {/* Header */} -
-

- {t('myApplications.title')} -

-

{t('myApplications.subtitle')}

-
- - {/* Stats Cards */} -
- -

{t('myApplications.stats.total')}

-

{stats.total}

-
- -

{t('myApplications.stats.pending')}

-

{stats.pending}

-
- -

{t('myApplications.stats.approved')}

-

{stats.approved}

-
- -

{t('myApplications.stats.rejected')}

-

{stats.rejected}

-
-
- - {/* Filters */} -
- {(['all', 'PENDING', 'APPROVED', 'REJECTED'] as FilterType[]).map((filterOption) => ( - - ))} -
- - {/* Applications List */} - {filteredApplications.length === 0 ? ( - - -

{t('myApplications.empty.title')}

-

{t('myApplications.empty.message')}

- -
- ) : ( -
- {filteredApplications.map((application) => ( - -
-
- {/* Application Info */} -
-
- -
-
-

- {application.title} -

- {nonprofitJobs.has(application.jobPostId) && ( - - - {t('nonProfitJobs.volunteerOpportunity')} - - )} -
-

{application.company}

-
-
- -
- - - {t('myApplications.applicationCard.appliedOn', { - date: formatDate(application.appliedDate), - })} - - - {t(`myApplications.applicationCard.status.${application.status}`)} - -
-
- - {/* Actions */} -
- - - {application.cvUrl && ( - - )} - -
-
-
-
- ))} -
- )} - - {/* Application Details Modal */} - - - - {t('myApplications.details.title')} - - {selectedApplication && ( -
-
-
-

{selectedApplication.title}

- {nonprofitJobs.has(selectedApplication.jobPostId) && ( - - - {t('nonProfitJobs.volunteerOpportunity')} - - )} -
-

{selectedApplication.company}

-
- -
-
- {t('myApplications.details.status')}: - - {t(`myApplications.applicationCard.status.${selectedApplication.status}`)} - -
-
- {t('myApplications.details.appliedDate')}: - {formatDate(selectedApplication.appliedDate)} -
-
- - {selectedApplication.coverLetter && ( -
-

{t('myApplications.details.coverLetter')}

-
- {selectedApplication.coverLetter} -
-
- )} - - {selectedApplication.specialNeeds && ( -
-

{t('myApplications.details.specialNeeds')}

-
- {selectedApplication.specialNeeds} -
-
- )} - - {selectedApplication.feedback && ( -
-

{t('myApplications.details.feedback')}

-
- {selectedApplication.feedback} -
-
- )} - - {selectedApplication.cvUrl && ( -
-

{t('myApplications.details.cv')}

- -
- )} -
- )} - - - -
-
- - {/* Withdraw Confirmation Modal */} - !open && setApplicationToWithdraw(null)}> - - - {t('myApplications.withdraw.title')} - {t('myApplications.withdraw.message')} - - - - - - - -
- ); -} +import { useState, useEffect, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Briefcase, MapPin, Download, ExternalLink, Trash2, FileText, Heart } from 'lucide-react'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@shared/components/ui/dialog'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import type { JobApplicationResponse, JobApplicationStatus } from '@shared/types/api.types'; +import { getApplicationsByJobSeeker, deleteApplication, getCvUrl } from '@modules/applications/services/applications.service'; +import { getJobById } from '@modules/jobs/services/jobs.service'; +import { useAuth } from '@shared/contexts/AuthContext'; + +type FilterType = 'all' | 'PENDING' | 'APPROVED' | 'REJECTED'; + +export default function MyApplicationsPage() { + const { user } = useAuth(); + const { t, i18n } = useTranslation('common'); + const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; + const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; + + const [applications, setApplications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState('all'); + const [nonprofitJobs, setNonprofitJobs] = useState>(new Set()); + + // Detail modal state + const [selectedApplication, setSelectedApplication] = useState(null); + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + + // Withdraw modal state + const [applicationToWithdraw, setApplicationToWithdraw] = useState(null); + const [isWithdrawing, setIsWithdrawing] = useState(false); + + // Cache for job details to avoid repeated API calls + const jobDetailsCache = useMemo(() => new Map(), []); + + // Helper function to get the correct job detail route + const getJobDetailRoute = (application: JobApplicationResponse): string => { + const isNonprofit = nonprofitJobs.has(application.jobPostId); + return isNonprofit ? `/nonprofit-jobs/${application.jobPostId}` : `/jobs/${application.jobPostId}`; + }; + + // Function to check if a job is nonprofit (with caching) + const checkIfNonprofitJob = async (jobPostId: number): Promise => { + if (jobDetailsCache.has(jobPostId)) { + return jobDetailsCache.get(jobPostId); + } + + try { + const jobDetails = await getJobById(jobPostId); + const isNonprofit = !!jobDetails.nonProfit; + jobDetailsCache.set(jobPostId, isNonprofit); + return isNonprofit; + } catch (error) { + // If we can't fetch job details, default to regular job + console.warn(`Could not fetch details for job ${jobPostId}:`, error); + jobDetailsCache.set(jobPostId, false); + return false; + } + }; + + useEffect(() => { + const fetchApplications = async () => { + if (!user) return; + + try { + setIsLoading(true); + setError(null); + + const apps = await getApplicationsByJobSeeker(user.id); + + setApplications(apps); + + // Check which jobs are nonprofit jobs (batch processing for better performance) + const nonprofitJobIds = new Set(); + const uniqueJobIds = [...new Set(apps.map((app) => app.jobPostId))]; + + await Promise.all( + uniqueJobIds.map(async (jobPostId: number) => { + const isNonprofit = await checkIfNonprofitJob(jobPostId); + if (isNonprofit) { + nonprofitJobIds.add(jobPostId); + } + }) + ); + + setNonprofitJobs(nonprofitJobIds); + } catch (err) { + console.error('Error fetching applications:', err); + setError(t('myApplications.errors.loadFailed')); + } finally { + setIsLoading(false); + } + }; + + fetchApplications(); + }, [user, t, jobDetailsCache]); + + const filteredApplications = useMemo(() => { + if (filter === 'all') { + return applications; + } + return applications.filter((app) => app.status === filter); + }, [applications, filter]); + + const stats = useMemo(() => { + return { + total: applications.length, + pending: applications.filter((app) => app.status === 'PENDING').length, + approved: applications.filter((app) => app.status === 'APPROVED').length, + rejected: applications.filter((app) => app.status === 'REJECTED').length, + }; + }, [applications]); + + const handleWithdraw = async () => { + if (!applicationToWithdraw) return; + + try { + setIsWithdrawing(true); + await deleteApplication(applicationToWithdraw); + + // Remove from list + setApplications((prev) => prev.filter((app) => app.id !== applicationToWithdraw)); + + setApplicationToWithdraw(null); + } catch (err) { + console.error('Error withdrawing application:', err); + setError(t('myApplications.withdraw.error')); + } finally { + setIsWithdrawing(false); + } + }; + + const handleDownloadCV = async (applicationId: number) => { + try { + const cvUrl = await getCvUrl(applicationId); + // Open in new tab + window.open(cvUrl, '_blank'); + } catch (err) { + console.error('Error downloading CV:', err); + } + }; + + const formatDate = (isoDate: string) => { + const date = new Date(isoDate); + return date.toLocaleDateString(resolvedLanguage, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const getStatusBadgeVariant = (status: JobApplicationStatus) => { + switch (status) { + case 'PENDING': + return 'default'; + case 'APPROVED': + return 'success'; + case 'REJECTED': + return 'destructive'; + default: + return 'default'; + } + }; + + if (isLoading) { + return ; + } + + if (error && applications.length === 0) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+

+ {t('myApplications.title')} +

+

{t('myApplications.subtitle')}

+
+ + {/* Stats Cards */} +
+ +

{t('myApplications.stats.total')}

+

{stats.total}

+
+ +

{t('myApplications.stats.pending')}

+

{stats.pending}

+
+ +

{t('myApplications.stats.approved')}

+

{stats.approved}

+
+ +

{t('myApplications.stats.rejected')}

+

{stats.rejected}

+
+
+ + {/* Filters */} +
+ {(['all', 'PENDING', 'APPROVED', 'REJECTED'] as FilterType[]).map((filterOption) => ( + + ))} +
+ + {/* Applications List */} + {filteredApplications.length === 0 ? ( + + +

{t('myApplications.empty.title')}

+

{t('myApplications.empty.message')}

+ +
+ ) : ( +
+ {filteredApplications.map((application) => ( + +
+
+ {/* Application Info */} +
+
+ +
+
+

+ {application.title} +

+ {nonprofitJobs.has(application.jobPostId) && ( + + + {t('nonProfitJobs.volunteerOpportunity')} + + )} +
+

{application.company}

+
+
+ +
+ + + {t('myApplications.applicationCard.appliedOn', { + date: formatDate(application.appliedDate), + })} + + + {t(`myApplications.applicationCard.status.${application.status}`)} + +
+
+ + {/* Actions */} +
+ + + {application.cvUrl && ( + + )} + +
+
+
+
+ ))} +
+ )} + + {/* Application Details Modal */} + + + + {t('myApplications.details.title')} + + {selectedApplication && ( +
+
+
+

{selectedApplication.title}

+ {nonprofitJobs.has(selectedApplication.jobPostId) && ( + + + {t('nonProfitJobs.volunteerOpportunity')} + + )} +
+

{selectedApplication.company}

+
+ +
+
+ {t('myApplications.details.status')}: + + {t(`myApplications.applicationCard.status.${selectedApplication.status}`)} + +
+
+ {t('myApplications.details.appliedDate')}: + {formatDate(selectedApplication.appliedDate)} +
+
+ + {selectedApplication.coverLetter && ( +
+

{t('myApplications.details.coverLetter')}

+
+ {selectedApplication.coverLetter} +
+
+ )} + + {selectedApplication.specialNeeds && ( +
+

{t('myApplications.details.specialNeeds')}

+
+ {selectedApplication.specialNeeds} +
+
+ )} + + {selectedApplication.feedback && ( +
+

{t('myApplications.details.feedback')}

+
+ {selectedApplication.feedback} +
+
+ )} + + {selectedApplication.cvUrl && ( +
+

{t('myApplications.details.cv')}

+ +
+ )} +
+ )} + + + +
+
+ + {/* Withdraw Confirmation Modal */} + !open && setApplicationToWithdraw(null)}> + + + {t('myApplications.withdraw.title')} + {t('myApplications.withdraw.message')} + + + + + + + +
+ ); +} diff --git a/apps/jobboard-frontend/src/pages/EmailVerificationPage.tsx b/apps/jobboard-frontend/src/modules/auth/pages/EmailVerificationPage.tsx similarity index 96% rename from apps/jobboard-frontend/src/pages/EmailVerificationPage.tsx rename to apps/jobboard-frontend/src/modules/auth/pages/EmailVerificationPage.tsx index f19243fe..d4936e74 100644 --- a/apps/jobboard-frontend/src/pages/EmailVerificationPage.tsx +++ b/apps/jobboard-frontend/src/modules/auth/pages/EmailVerificationPage.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import axios from 'axios'; -import CenteredLoader from '../components/CenteredLoader'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; -import { Button } from '../components/ui/button'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shared/components/ui/card'; +import { Button } from '@shared/components/ui/button'; import { CheckCircle, XCircle } from 'lucide-react'; const API_BASE_URL = import.meta.env.VITE_API_URL?.endsWith('/api') diff --git a/apps/jobboard-frontend/src/pages/ForgotPasswordPage.tsx b/apps/jobboard-frontend/src/modules/auth/pages/ForgotPasswordPage.tsx similarity index 98% rename from apps/jobboard-frontend/src/pages/ForgotPasswordPage.tsx rename to apps/jobboard-frontend/src/modules/auth/pages/ForgotPasswordPage.tsx index 1f22a7cd..fbb13404 100644 --- a/apps/jobboard-frontend/src/pages/ForgotPasswordPage.tsx +++ b/apps/jobboard-frontend/src/modules/auth/pages/ForgotPasswordPage.tsx @@ -6,8 +6,8 @@ import axios from 'axios'; import { Link } from 'react-router-dom'; import { toast } from 'react-toastify'; import { forgotPasswordSchema, type ForgotPasswordFormData } from '../schemas/forgot-password.schema'; -import { Button } from '../components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; +import { Button } from '@shared/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shared/components/ui/card'; const API_BASE_URL = import.meta.env.VITE_API_URL?.endsWith('/api') ? import.meta.env.VITE_API_URL diff --git a/apps/jobboard-frontend/src/pages/LoginPage.tsx b/apps/jobboard-frontend/src/modules/auth/pages/LoginPage.tsx similarity index 98% rename from apps/jobboard-frontend/src/pages/LoginPage.tsx rename to apps/jobboard-frontend/src/modules/auth/pages/LoginPage.tsx index c850ec35..91dbeb0c 100644 --- a/apps/jobboard-frontend/src/pages/LoginPage.tsx +++ b/apps/jobboard-frontend/src/modules/auth/pages/LoginPage.tsx @@ -6,9 +6,9 @@ import axios from 'axios'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import { toast } from 'react-toastify'; import { loginSchema, type LoginFormData } from '../schemas/login.schema'; -import { Button } from '../components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; -import { useAuthActions } from '@/stores/authStore'; +import { Button } from '@shared/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shared/components/ui/card'; +import { useAuthActions } from '@shared/stores/authStore'; const API_BASE_URL = import.meta.env.VITE_API_URL?.endsWith('/api') ? import.meta.env.VITE_API_URL diff --git a/apps/jobboard-frontend/src/pages/RegisterPage.tsx b/apps/jobboard-frontend/src/modules/auth/pages/RegisterPage.tsx similarity index 99% rename from apps/jobboard-frontend/src/pages/RegisterPage.tsx rename to apps/jobboard-frontend/src/modules/auth/pages/RegisterPage.tsx index 81a14cec..02ffc559 100644 --- a/apps/jobboard-frontend/src/pages/RegisterPage.tsx +++ b/apps/jobboard-frontend/src/modules/auth/pages/RegisterPage.tsx @@ -6,8 +6,8 @@ import axios from 'axios'; import { Link } from 'react-router-dom'; import { toast } from 'react-toastify'; import { registerSchema, type RegisterFormData } from '../schemas/register.schema'; -import { Button } from '../components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; +import { Button } from '@shared/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shared/components/ui/card'; const API_BASE_URL = import.meta.env.VITE_API_URL?.endsWith('/api') ? import.meta.env.VITE_API_URL diff --git a/apps/jobboard-frontend/src/pages/ResetPasswordPage.tsx b/apps/jobboard-frontend/src/modules/auth/pages/ResetPasswordPage.tsx similarity index 98% rename from apps/jobboard-frontend/src/pages/ResetPasswordPage.tsx rename to apps/jobboard-frontend/src/modules/auth/pages/ResetPasswordPage.tsx index c236ba79..310f1fe6 100644 --- a/apps/jobboard-frontend/src/pages/ResetPasswordPage.tsx +++ b/apps/jobboard-frontend/src/modules/auth/pages/ResetPasswordPage.tsx @@ -6,8 +6,8 @@ import axios from 'axios'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { toast } from 'react-toastify'; import { resetPasswordSchema, type ResetPasswordFormData } from '../schemas/reset-password.schema'; -import { Button } from '../components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; +import { Button } from '@shared/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shared/components/ui/card'; const API_BASE_URL = import.meta.env.VITE_API_URL?.endsWith('/api') ? import.meta.env.VITE_API_URL diff --git a/apps/jobboard-frontend/src/pages/VerifyOtpPage.tsx b/apps/jobboard-frontend/src/modules/auth/pages/VerifyOtpPage.tsx similarity index 96% rename from apps/jobboard-frontend/src/pages/VerifyOtpPage.tsx rename to apps/jobboard-frontend/src/modules/auth/pages/VerifyOtpPage.tsx index ff8a70e8..992cb77d 100644 --- a/apps/jobboard-frontend/src/pages/VerifyOtpPage.tsx +++ b/apps/jobboard-frontend/src/modules/auth/pages/VerifyOtpPage.tsx @@ -6,9 +6,9 @@ import axios from 'axios'; import { useNavigate, useLocation } from 'react-router-dom'; import { toast } from 'react-toastify'; import { verifyOtpSchema, type VerifyOtpFormData } from '../schemas/verify-otp.schema'; -import { Button } from '../components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'; -import { useAuthActions } from '@/stores/authStore'; +import { Button } from '@shared/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shared/components/ui/card'; +import { useAuthActions } from '@shared/stores/authStore'; const API_BASE_URL = import.meta.env.VITE_API_URL?.endsWith('/api') ? import.meta.env.VITE_API_URL diff --git a/apps/jobboard-frontend/src/pages/ChatPage.tsx b/apps/jobboard-frontend/src/modules/chat/pages/ChatPage.tsx similarity index 98% rename from apps/jobboard-frontend/src/pages/ChatPage.tsx rename to apps/jobboard-frontend/src/modules/chat/pages/ChatPage.tsx index 56a32175..ae3dcbd0 100644 --- a/apps/jobboard-frontend/src/pages/ChatPage.tsx +++ b/apps/jobboard-frontend/src/modules/chat/pages/ChatPage.tsx @@ -1,17 +1,17 @@ import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useSearchParams } from 'react-router-dom'; -import ChatRoomList from '@/components/chat/ChatRoomList'; -import ChatInterface from '@/components/chat/ChatInterface'; -import type { ChatRoom, ChatRoomForUser, ChatMessage } from '@/types/chat'; -import { useAuth } from '@/contexts/AuthContext'; -import { useAuthStore } from '@/stores/authStore'; -import { getMenteeMentorships, getMentorMentorshipRequests } from '@/services/mentorship.service'; -import { getChatHistory, ChatWebSocket } from '@/services/chat.service'; -import { profileService } from '@/services/profile.service'; -import type { PublicProfile } from '@/types/profile.types'; -import type { MentorshipDetailsDTO, MentorshipRequestDTO } from '@/types/api.types'; -import CenteredLoader from '@/components/CenteredLoader'; +import ChatRoomList from '@modules/chat/components/chat/ChatRoomList'; +import ChatInterface from '@modules/chat/components/chat/ChatInterface'; +import type { ChatRoom, ChatRoomForUser, ChatMessage } from '@shared/types/chat'; +import { useAuth } from '@shared/contexts/AuthContext'; +import { useAuthStore } from '@shared/stores/authStore'; +import { getMenteeMentorships, getMentorMentorshipRequests } from '@modules/mentorship/services/mentorship.service'; +import { getChatHistory, ChatWebSocket } from '@modules/chat/services/chat.service'; +import { profileService } from '@modules/profile/services/profile.service'; +import type { PublicProfile } from '@shared/types/profile.types'; +import type { MentorshipDetailsDTO, MentorshipRequestDTO } from '@shared/types/api.types'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; import { toast } from 'react-toastify'; const ChatPage = () => { diff --git a/apps/jobboard-frontend/src/pages/EmployerDashboardPage.tsx b/apps/jobboard-frontend/src/modules/employer/pages/EmployerDashboardPage.tsx similarity index 93% rename from apps/jobboard-frontend/src/pages/EmployerDashboardPage.tsx rename to apps/jobboard-frontend/src/modules/employer/pages/EmployerDashboardPage.tsx index 544907ff..adc343a8 100644 --- a/apps/jobboard-frontend/src/pages/EmployerDashboardPage.tsx +++ b/apps/jobboard-frontend/src/modules/employer/pages/EmployerDashboardPage.tsx @@ -1,473 +1,473 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { toast } from 'react-toastify'; -import { ChevronDown, ChevronUp, Building2, Plus, UserPlus, MapPin, Star, Briefcase } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Card } from '@/components/ui/card'; -import { CreateJobPostModal } from '@/components/jobs/CreateJobPostModal'; -import { getJobsByEmployer } from '@/services/jobs.service'; -import { getApplications } from '@/services/applications.service'; -import { getMyWorkplaces } from '@/services/employer.service'; -import { CreateWorkplaceModal } from '@/components/workplace/CreateWorkplaceModal'; -import { JoinWorkplaceModal } from '@/components/workplace/JoinWorkplaceModal'; -import CenteredLoader from '@/components/CenteredLoader'; -import { useAuthStore } from '@/stores/authStore'; -import type { JobPostResponse } from '@/types/api.types'; -import type { EmployerWorkplaceBrief, WorkplaceBriefResponse } from '@/types/workplace.types'; - -type JobPosting = { - id: number; - title: string; - status: string; - applications: number; - workplaceId: number | undefined; - workplace?: WorkplaceBriefResponse; -}; - -type WorkplaceWithJobs = { - workplace: WorkplaceBriefResponse; - role: string; - jobs: JobPosting[]; - isExpanded: boolean; -}; - -export default function EmployerDashboardPage() { - const navigate = useNavigate(); - const { user } = useAuthStore(); - const [workplacesWithJobs, setWorkplacesWithJobs] = useState([]); - const [employerWorkplaces, setEmployerWorkplaces] = useState([]); - const [showCreateJobModal, setShowCreateJobModal] = useState(false); - const [selectedWorkplaceId, setSelectedWorkplaceId] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [showCreateModal, setShowCreateModal] = useState(false); - const [showJoinModal, setShowJoinModal] = useState(false); - const { t, i18n } = useTranslation('common'); - const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; - const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; - - const fetchData = useCallback(async () => { - if (!user) { - setError('auth'); - setIsLoading(false); - return; - } - - try { - setIsLoading(true); - setError(null); - - const [workplaces, jobs] = await Promise.all([ - getMyWorkplaces(), - getJobsByEmployer(user.id), - ]); - - setEmployerWorkplaces(workplaces); - - const getNormalizedJobId = (job: JobPostResponse) => - job.id ?? job.jobPostId ?? job.jobId; - - const jobsWithCounts = await Promise.all( - jobs.map(async (job) => { - const normalizedId = getNormalizedJobId(job); - if (normalizedId === undefined) { - return null; - } - - try { - const applications = await getApplications({ jobPostId: normalizedId }); - return { - id: normalizedId, - title: job.title, - status: 'OPEN', - applications: applications.length, - workplaceId: job.workplaceId, - workplace: job.workplace, - }; - } catch { - return { - id: normalizedId, - title: job.title, - status: 'OPEN', - applications: 0, - workplaceId: job.workplaceId, - workplace: job.workplace, - }; - } - }) - ); - - const validJobs = jobsWithCounts.filter((job): job is JobPosting & { workplace: WorkplaceBriefResponse } => - job !== null && job.workplace !== undefined - ); - - // Get all workplace IDs that the employer belongs to - const employerWorkplaceIds = new Set(workplaces.map((wp) => wp.workplace.id)); - - const workplacesData: WorkplaceWithJobs[] = workplaces.map((wp: EmployerWorkplaceBrief) => ({ - workplace: wp.workplace, - role: wp.role, - // Match by workplace.id (from the workplace object) as the primary source of truth - jobs: validJobs.filter((job) => job.workplace.id === wp.workplace.id), - isExpanded: true, - })); - - // Jobs are unassigned if their workplace.id doesn't match any employer workplace - const unassignedJobs = validJobs.filter((job) => - !employerWorkplaceIds.has(job.workplace.id) - ); - if (unassignedJobs.length > 0) { - workplacesData.push({ - workplace: { - id: -1, - companyName: t('employerDashboard.unassignedJobs'), - sector: '', - location: '', - overallAvg: 0, - ethicalTags: [], - ethicalAverages: {}, - }, - role: 'OWNER', - jobs: unassignedJobs, - isExpanded: true, - }); - } - - setWorkplacesWithJobs(workplacesData); - } catch (err) { - console.error('Error fetching dashboard data:', err); - setError('fetch_error'); - } finally { - setIsLoading(false); - } - }, [user, t]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - const toggleWorkplaceExpand = (workplaceId: number) => { - setWorkplacesWithJobs((prev) => - prev.map((wp) => - wp.workplace.id === workplaceId ? { ...wp, isExpanded: !wp.isExpanded } : wp - ) - ); - }; - - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case 'OPEN': - return 'default'; - case 'ACTIVE': - return 'secondary'; - case 'PAUSED': - return 'destructive'; - default: - return 'default'; - } - }; - - const getStatusLabel = (status: string) => { - const key = status.toLowerCase(); - return t(`employerDashboard.statusLabels.${key}`, { defaultValue: status }); - }; - - const getErrorMessage = () => { - if (error === 'auth') { - return t('auth.login.errors.generic'); - } - return t('employerDashboard.loadingError'); - }; - - const handleWorkplaceCreated = () => { - toast.success(t('workplace.createModal.workplaceCreatedMessage')); - }; - - const handleJoinSuccess = () => { - setShowJoinModal(false); - toast.success(t('workplace.joinModal.requestSubmittedMessage', { - company: 'the workplace', // Generic message since we don't have the specific workplace name here - })); - window.location.reload(); - }; - - const handleCreateModalOpenChange = (open: boolean) => { - setShowCreateJobModal(open); - if (!open) { - setSelectedWorkplaceId(null); - } - }; - - const handleOpenCreateJob = (workplaceId?: number) => { - setSelectedWorkplaceId(workplaceId ?? null); - setShowCreateJobModal(true); - }; - - const handleJobCreated = async () => { - handleCreateModalOpenChange(false); - toast.success(t('createJob.submitSuccess')); - await fetchData(); - }; - - const selectedWorkplace = - selectedWorkplaceId !== null - ? employerWorkplaces.find((wp) => wp.workplace.id === selectedWorkplaceId) ?? null - : null; - - const totalJobs = workplacesWithJobs.reduce((sum, wp) => sum + wp.jobs.length, 0); - const hasWorkplaces = employerWorkplaces.length > 0; - - if (isLoading) { - return ; - } - - return ( -
-
-
-
-
-

- {t('employerDashboard.title')} -

-

- {t('employerDashboard.subtitle', { - defaultValue: t('employerDashboard.currentPostings'), - })} -

-
-
- {hasWorkplaces && ( - - )} -
-
-
- - {error ? ( - -
-

{getErrorMessage()}

- -
-
- ) : !hasWorkplaces && totalJobs === 0 ? ( - // No workplaces state - -
- -

- {t('employerDashboard.noWorkplaces.title')} -

-

- {t('employerDashboard.noWorkplaces.description')} -

-
- - -
-
-
- ) : ( - // Grouped workplaces view -
- {/* Workplace action buttons */} -
- - -
- - {workplacesWithJobs.map((wp) => ( - - {/* Workplace Header */} -
toggleWorkplaceExpand(wp.workplace.id)} - > -
-
- {wp.workplace.imageUrl ? ( - {wp.workplace.companyName} - ) : ( -
- -
- )} -
-

{wp.workplace.companyName}

-
- {wp.workplace.sector && ( - {wp.workplace.sector} - )} - {wp.workplace.location && ( - - - {wp.workplace.location} - - )} - {wp.workplace.overallAvg > 0 && ( - - - {wp.workplace.overallAvg.toFixed(1)} - - )} -
-
-
-
-
- - - {wp.jobs.length} {t('employerDashboard.jobCount', { count: wp.jobs.length })} - -
- {wp.isExpanded ? ( - - ) : ( - - )} -
-
-
- - {/* Jobs List (collapsible) */} - {wp.isExpanded && ( -
- {wp.jobs.length === 0 ? ( -
-

- {t('employerDashboard.noJobsInWorkplace')} -

- {wp.workplace.id !== -1 && ( - - )} -
- ) : ( -
- - - - - - - - - - - {wp.jobs.map((job) => ( - - - - - - - ))} - -
- {t('employerDashboard.table.jobTitle')} - - {t('employerDashboard.table.status')} - - {t('employerDashboard.table.applications')} - - {t('employerDashboard.table.actions')} -
-
{job.title}
-
- - {getStatusLabel(job.status)} - - - {job.applications} - -
- -
-
-
- )} - {/* Create job button for this workplace */} - {wp.workplace.id !== -1 && wp.jobs.length > 0 && ( -
- -
- )} -
- )} -
- ))} -
- )} -
- - - - -
- ); -} +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { ChevronDown, ChevronUp, Building2, Plus, UserPlus, MapPin, Star, Briefcase } from 'lucide-react'; +import { Button } from '@shared/components/ui/button'; +import { Badge } from '@shared/components/ui/badge'; +import { Card } from '@shared/components/ui/card'; +import { CreateJobPostModal } from '@modules/jobs/components/jobs/CreateJobPostModal'; +import { getJobsByEmployer } from '@modules/jobs/services/jobs.service'; +import { getApplications } from '@modules/applications/services/applications.service'; +import { getMyWorkplaces } from '@modules/employer/services/employer.service'; +import { CreateWorkplaceModal } from '@modules/workplace/components/workplace/CreateWorkplaceModal'; +import { JoinWorkplaceModal } from '@modules/workplace/components/workplace/JoinWorkplaceModal'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import { useAuthStore } from '@shared/stores/authStore'; +import type { JobPostResponse } from '@shared/types/api.types'; +import type { EmployerWorkplaceBrief, WorkplaceBriefResponse } from '@shared/types/workplace.types'; + +type JobPosting = { + id: number; + title: string; + status: string; + applications: number; + workplaceId: number | undefined; + workplace?: WorkplaceBriefResponse; +}; + +type WorkplaceWithJobs = { + workplace: WorkplaceBriefResponse; + role: string; + jobs: JobPosting[]; + isExpanded: boolean; +}; + +export default function EmployerDashboardPage() { + const navigate = useNavigate(); + const { user } = useAuthStore(); + const [workplacesWithJobs, setWorkplacesWithJobs] = useState([]); + const [employerWorkplaces, setEmployerWorkplaces] = useState([]); + const [showCreateJobModal, setShowCreateJobModal] = useState(false); + const [selectedWorkplaceId, setSelectedWorkplaceId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showJoinModal, setShowJoinModal] = useState(false); + const { t, i18n } = useTranslation('common'); + const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; + const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; + + const fetchData = useCallback(async () => { + if (!user) { + setError('auth'); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + setError(null); + + const [workplaces, jobs] = await Promise.all([ + getMyWorkplaces(), + getJobsByEmployer(user.id), + ]); + + setEmployerWorkplaces(workplaces); + + const getNormalizedJobId = (job: JobPostResponse) => + job.id ?? job.jobPostId ?? job.jobId; + + const jobsWithCounts = await Promise.all( + jobs.map(async (job) => { + const normalizedId = getNormalizedJobId(job); + if (normalizedId === undefined) { + return null; + } + + try { + const applications = await getApplications({ jobPostId: normalizedId }); + return { + id: normalizedId, + title: job.title, + status: 'OPEN', + applications: applications.length, + workplaceId: job.workplaceId, + workplace: job.workplace, + }; + } catch { + return { + id: normalizedId, + title: job.title, + status: 'OPEN', + applications: 0, + workplaceId: job.workplaceId, + workplace: job.workplace, + }; + } + }) + ); + + const validJobs = jobsWithCounts.filter((job): job is JobPosting & { workplace: WorkplaceBriefResponse } => + job !== null && job.workplace !== undefined + ); + + // Get all workplace IDs that the employer belongs to + const employerWorkplaceIds = new Set(workplaces.map((wp) => wp.workplace.id)); + + const workplacesData: WorkplaceWithJobs[] = workplaces.map((wp: EmployerWorkplaceBrief) => ({ + workplace: wp.workplace, + role: wp.role, + // Match by workplace.id (from the workplace object) as the primary source of truth + jobs: validJobs.filter((job) => job.workplace.id === wp.workplace.id), + isExpanded: true, + })); + + // Jobs are unassigned if their workplace.id doesn't match any employer workplace + const unassignedJobs = validJobs.filter((job) => + !employerWorkplaceIds.has(job.workplace.id) + ); + if (unassignedJobs.length > 0) { + workplacesData.push({ + workplace: { + id: -1, + companyName: t('employerDashboard.unassignedJobs'), + sector: '', + location: '', + overallAvg: 0, + ethicalTags: [], + ethicalAverages: {}, + }, + role: 'OWNER', + jobs: unassignedJobs, + isExpanded: true, + }); + } + + setWorkplacesWithJobs(workplacesData); + } catch (err) { + console.error('Error fetching dashboard data:', err); + setError('fetch_error'); + } finally { + setIsLoading(false); + } + }, [user, t]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const toggleWorkplaceExpand = (workplaceId: number) => { + setWorkplacesWithJobs((prev) => + prev.map((wp) => + wp.workplace.id === workplaceId ? { ...wp, isExpanded: !wp.isExpanded } : wp + ) + ); + }; + + const getStatusBadgeVariant = (status: string) => { + switch (status) { + case 'OPEN': + return 'default'; + case 'ACTIVE': + return 'secondary'; + case 'PAUSED': + return 'destructive'; + default: + return 'default'; + } + }; + + const getStatusLabel = (status: string) => { + const key = status.toLowerCase(); + return t(`employerDashboard.statusLabels.${key}`, { defaultValue: status }); + }; + + const getErrorMessage = () => { + if (error === 'auth') { + return t('auth.login.errors.generic'); + } + return t('employerDashboard.loadingError'); + }; + + const handleWorkplaceCreated = () => { + toast.success(t('workplace.createModal.workplaceCreatedMessage')); + }; + + const handleJoinSuccess = () => { + setShowJoinModal(false); + toast.success(t('workplace.joinModal.requestSubmittedMessage', { + company: 'the workplace', // Generic message since we don't have the specific workplace name here + })); + window.location.reload(); + }; + + const handleCreateModalOpenChange = (open: boolean) => { + setShowCreateJobModal(open); + if (!open) { + setSelectedWorkplaceId(null); + } + }; + + const handleOpenCreateJob = (workplaceId?: number) => { + setSelectedWorkplaceId(workplaceId ?? null); + setShowCreateJobModal(true); + }; + + const handleJobCreated = async () => { + handleCreateModalOpenChange(false); + toast.success(t('createJob.submitSuccess')); + await fetchData(); + }; + + const selectedWorkplace = + selectedWorkplaceId !== null + ? employerWorkplaces.find((wp) => wp.workplace.id === selectedWorkplaceId) ?? null + : null; + + const totalJobs = workplacesWithJobs.reduce((sum, wp) => sum + wp.jobs.length, 0); + const hasWorkplaces = employerWorkplaces.length > 0; + + if (isLoading) { + return ; + } + + return ( +
+
+
+
+
+

+ {t('employerDashboard.title')} +

+

+ {t('employerDashboard.subtitle', { + defaultValue: t('employerDashboard.currentPostings'), + })} +

+
+
+ {hasWorkplaces && ( + + )} +
+
+
+ + {error ? ( + +
+

{getErrorMessage()}

+ +
+
+ ) : !hasWorkplaces && totalJobs === 0 ? ( + // No workplaces state + +
+ +

+ {t('employerDashboard.noWorkplaces.title')} +

+

+ {t('employerDashboard.noWorkplaces.description')} +

+
+ + +
+
+
+ ) : ( + // Grouped workplaces view +
+ {/* Workplace action buttons */} +
+ + +
+ + {workplacesWithJobs.map((wp) => ( + + {/* Workplace Header */} +
toggleWorkplaceExpand(wp.workplace.id)} + > +
+
+ {wp.workplace.imageUrl ? ( + {wp.workplace.companyName} + ) : ( +
+ +
+ )} +
+

{wp.workplace.companyName}

+
+ {wp.workplace.sector && ( + {wp.workplace.sector} + )} + {wp.workplace.location && ( + + + {wp.workplace.location} + + )} + {wp.workplace.overallAvg > 0 && ( + + + {wp.workplace.overallAvg.toFixed(1)} + + )} +
+
+
+
+
+ + + {wp.jobs.length} {t('employerDashboard.jobCount', { count: wp.jobs.length })} + +
+ {wp.isExpanded ? ( + + ) : ( + + )} +
+
+
+ + {/* Jobs List (collapsible) */} + {wp.isExpanded && ( +
+ {wp.jobs.length === 0 ? ( +
+

+ {t('employerDashboard.noJobsInWorkplace')} +

+ {wp.workplace.id !== -1 && ( + + )} +
+ ) : ( +
+ + + + + + + + + + + {wp.jobs.map((job) => ( + + + + + + + ))} + +
+ {t('employerDashboard.table.jobTitle')} + + {t('employerDashboard.table.status')} + + {t('employerDashboard.table.applications')} + + {t('employerDashboard.table.actions')} +
+
{job.title}
+
+ + {getStatusLabel(job.status)} + + + {job.applications} + +
+ +
+
+
+ )} + {/* Create job button for this workplace */} + {wp.workplace.id !== -1 && wp.jobs.length > 0 && ( +
+ +
+ )} +
+ )} +
+ ))} +
+ )} +
+ + + + +
+ ); +} diff --git a/apps/jobboard-frontend/src/pages/EmployerEditJobPostPage.tsx b/apps/jobboard-frontend/src/modules/employer/pages/EmployerEditJobPostPage.tsx similarity index 95% rename from apps/jobboard-frontend/src/pages/EmployerEditJobPostPage.tsx rename to apps/jobboard-frontend/src/modules/employer/pages/EmployerEditJobPostPage.tsx index 622a7d9a..9f9f2edd 100644 --- a/apps/jobboard-frontend/src/pages/EmployerEditJobPostPage.tsx +++ b/apps/jobboard-frontend/src/modules/employer/pages/EmployerEditJobPostPage.tsx @@ -1,16 +1,16 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Card } from '@/components/ui/card'; -import WorkplaceSelector from '@/components/workplace/WorkplaceSelector'; -import CenteredLoader from '@/components/CenteredLoader'; -import { getJobById, updateJob } from '@/services/jobs.service'; -import type { UpdateJobPostRequest, JobPostResponse } from '@/types/api.types'; -import type { EmployerWorkplaceBrief } from '@/types/workplace.types'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Label } from '@shared/components/ui/label'; +import { Checkbox } from '@shared/components/ui/checkbox'; +import { Card } from '@shared/components/ui/card'; +import WorkplaceSelector from '@modules/workplace/components/workplace/WorkplaceSelector'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import { getJobById, updateJob } from '@modules/jobs/services/jobs.service'; +import type { UpdateJobPostRequest, JobPostResponse } from '@shared/types/api.types'; +import type { EmployerWorkplaceBrief } from '@shared/types/workplace.types'; type JobPostFormData = { title: string; diff --git a/apps/jobboard-frontend/src/pages/EmployerJobPostDetailsPage.tsx b/apps/jobboard-frontend/src/modules/employer/pages/EmployerJobPostDetailsPage.tsx similarity index 93% rename from apps/jobboard-frontend/src/pages/EmployerJobPostDetailsPage.tsx rename to apps/jobboard-frontend/src/modules/employer/pages/EmployerJobPostDetailsPage.tsx index 066fc6e1..69860f83 100644 --- a/apps/jobboard-frontend/src/pages/EmployerJobPostDetailsPage.tsx +++ b/apps/jobboard-frontend/src/modules/employer/pages/EmployerJobPostDetailsPage.tsx @@ -1,255 +1,255 @@ -import { useState, useEffect } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; -import { ChevronRight, Edit } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { getJobById } from '@/services/jobs.service'; -import { getApplications } from '@/services/applications.service'; -import type { JobPostResponse, JobApplicationResponse } from '@/types/api.types'; -import CenteredLoader from '@/components/CenteredLoader'; - -import { useTranslation } from 'react-i18next'; -import { TAG_TO_KEY_MAP } from '@/constants/ethical-tags'; - -// Convert ethical tag names to translation keys -const getEthicalTagTranslationKey = (tag: string): string => { - return TAG_TO_KEY_MAP[tag as keyof typeof TAG_TO_KEY_MAP] || tag.toLowerCase().replace(/[^a-zA-Z0-9]/g, ''); -}; - -export default function EmployerJobPostDetailsPage() { - const { t } = useTranslation('common'); - const { jobId } = useParams<{ jobId: string }>(); - const navigate = useNavigate(); - const [jobPost, setJobPost] = useState(null); - const [applications, setApplications] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchJobAndApplications = async () => { - if (!jobId) return; - - try { - setIsLoading(true); - setError(null); - - const jobIdAsInt = parseInt(jobId, 10); - - // Fetch job details and applications in parallel - const [jobData, applicationsData] = await Promise.all([ - getJobById(jobIdAsInt), - getApplications({ jobPostId: jobIdAsInt }), - ]); - - setJobPost(jobData); - setApplications(applicationsData); - } catch (err) { - console.error('Error fetching job details:', err); - setError(t('employerJobPostDetails.error.load')); - } finally { - setIsLoading(false); - } - }; - - fetchJobAndApplications(); - }, [jobId, t]); - - const formatSalary = (min: number, max: number) => { - return `$${min.toLocaleString()} - $${max.toLocaleString()} per year`; - }; - - // Format date from ISO string - const formatDate = (isoDate: string) => { - const date = new Date(isoDate); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) return t('employerJobPostDetails.applied.today'); - if (diffDays === 1) return t('employerJobPostDetails.applied.yesterday'); - return t('employerJobPostDetails.applied.daysAgo', { count: diffDays }); - }; - - if (isLoading) { - return ; - } - - if (error || !jobPost) { - return ( -
-

- {error ? t('employerJobPostDetails.error.title') : t('employerJobPostDetails.error.notFound')} -

-

- {error || t('employerJobPostDetails.error.description')} -

- -
- ); - } - - // Parse contact info - let contactInfo: { name?: string; email?: string } = {}; - try { - contactInfo = typeof jobPost.contact === 'string' && jobPost.contact.startsWith('{') - ? JSON.parse(jobPost.contact) - : { email: jobPost.contact }; - } catch { - contactInfo = { email: jobPost.contact }; - } - - const ethicalTags = jobPost.workplace.ethicalTags; - - const jobPostId = jobPost.id ?? jobPost.jobPostId ?? jobPost.jobId; - - const handleEditClick = () => { - if (!jobPostId) return; - navigate(`/employer/jobs/${jobPostId}/edit`); - }; - - const handleViewApplication = (applicationId: number) => { - if (!jobPostId) return; - navigate(`/employer/jobs/${jobPostId}/applications/${applicationId}`); - }; - - return ( -
- {/* Breadcrumb */} - - - {/* Main Content */} -
- -
- {/* Header Section */} -
-
-

{jobPost.title}

-
- -
- - {/* Job Description */} -
-

{t('employerJobPostDetails.jobDescription')}

-

{jobPost.description}

-
- - {/* Salary Range */} -
-

{t('employerJobPostDetails.salaryRange')}

-

- {formatSalary(jobPost.minSalary, jobPost.maxSalary)} -

-
- - {/* Location */} -
-

{t('employerJobPostDetails.location')}

-

- {jobPost.remote ? t('employerJobPostDetails.remote') : jobPost.location} - {jobPost.remote && jobPost.location && ` (${jobPost.location})`} -

-
- - {/* Contact Information */} -
-

- {t('employerJobPostDetails.contact')} -

-

- {contactInfo.name && `${contactInfo.name}: `} - {contactInfo.email} -

-
- - {/* Ethical Tags */} - {ethicalTags.length > 0 && ( -
-

{t('employerJobPostDetails.ethicalPolicies')}

-
- {ethicalTags.map((tag, index) => ( - - {t(`ethicalTags.tags.${getEthicalTagTranslationKey(tag)}`, tag)} - - ))} -
-
- )} - - {/* Inclusive Opportunity */} - {jobPost.inclusiveOpportunity && ( -
-

- {t('employerJobPostDetails.inclusiveOpportunity')} -

-

- {t('employerJobPostDetails.inclusiveOpportunityDescription')} -

-
- )} - - {/* Applications Received */} -
-

- {t('employerJobPostDetails.applicationsReceived', { count: applications.length })} -

- {applications.length === 0 ? ( -

{t('employerJobPostDetails.noApplications')}

- ) : ( -
- {applications.map((application) => ( - -
-
- - - {application.applicantName - .split(' ') - .map((n) => n[0]) - .join('')} - - -
-

{application.applicantName}

-

{application.title}

-

{formatDate(application.appliedDate)}

-
-
- -
-
- ))} -
- )} -
-
-
-
-
- ); -} +import { useState, useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { ChevronRight, Edit } from 'lucide-react'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Avatar, AvatarFallback } from '@shared/components/ui/avatar'; +import { getJobById } from '@modules/jobs/services/jobs.service'; +import { getApplications } from '@modules/applications/services/applications.service'; +import type { JobPostResponse, JobApplicationResponse } from '@shared/types/api.types'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; + +import { useTranslation } from 'react-i18next'; +import { TAG_TO_KEY_MAP } from '@shared/constants/ethical-tags'; + +// Convert ethical tag names to translation keys +const getEthicalTagTranslationKey = (tag: string): string => { + return TAG_TO_KEY_MAP[tag as keyof typeof TAG_TO_KEY_MAP] || tag.toLowerCase().replace(/[^a-zA-Z0-9]/g, ''); +}; + +export default function EmployerJobPostDetailsPage() { + const { t } = useTranslation('common'); + const { jobId } = useParams<{ jobId: string }>(); + const navigate = useNavigate(); + const [jobPost, setJobPost] = useState(null); + const [applications, setApplications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchJobAndApplications = async () => { + if (!jobId) return; + + try { + setIsLoading(true); + setError(null); + + const jobIdAsInt = parseInt(jobId, 10); + + // Fetch job details and applications in parallel + const [jobData, applicationsData] = await Promise.all([ + getJobById(jobIdAsInt), + getApplications({ jobPostId: jobIdAsInt }), + ]); + + setJobPost(jobData); + setApplications(applicationsData); + } catch (err) { + console.error('Error fetching job details:', err); + setError(t('employerJobPostDetails.error.load')); + } finally { + setIsLoading(false); + } + }; + + fetchJobAndApplications(); + }, [jobId, t]); + + const formatSalary = (min: number, max: number) => { + return `$${min.toLocaleString()} - $${max.toLocaleString()} per year`; + }; + + // Format date from ISO string + const formatDate = (isoDate: string) => { + const date = new Date(isoDate); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return t('employerJobPostDetails.applied.today'); + if (diffDays === 1) return t('employerJobPostDetails.applied.yesterday'); + return t('employerJobPostDetails.applied.daysAgo', { count: diffDays }); + }; + + if (isLoading) { + return ; + } + + if (error || !jobPost) { + return ( +
+

+ {error ? t('employerJobPostDetails.error.title') : t('employerJobPostDetails.error.notFound')} +

+

+ {error || t('employerJobPostDetails.error.description')} +

+ +
+ ); + } + + // Parse contact info + let contactInfo: { name?: string; email?: string } = {}; + try { + contactInfo = typeof jobPost.contact === 'string' && jobPost.contact.startsWith('{') + ? JSON.parse(jobPost.contact) + : { email: jobPost.contact }; + } catch { + contactInfo = { email: jobPost.contact }; + } + + const ethicalTags = jobPost.workplace.ethicalTags; + + const jobPostId = jobPost.id ?? jobPost.jobPostId ?? jobPost.jobId; + + const handleEditClick = () => { + if (!jobPostId) return; + navigate(`/employer/jobs/${jobPostId}/edit`); + }; + + const handleViewApplication = (applicationId: number) => { + if (!jobPostId) return; + navigate(`/employer/jobs/${jobPostId}/applications/${applicationId}`); + }; + + return ( +
+ {/* Breadcrumb */} + + + {/* Main Content */} +
+ +
+ {/* Header Section */} +
+
+

{jobPost.title}

+
+ +
+ + {/* Job Description */} +
+

{t('employerJobPostDetails.jobDescription')}

+

{jobPost.description}

+
+ + {/* Salary Range */} +
+

{t('employerJobPostDetails.salaryRange')}

+

+ {formatSalary(jobPost.minSalary, jobPost.maxSalary)} +

+
+ + {/* Location */} +
+

{t('employerJobPostDetails.location')}

+

+ {jobPost.remote ? t('employerJobPostDetails.remote') : jobPost.location} + {jobPost.remote && jobPost.location && ` (${jobPost.location})`} +

+
+ + {/* Contact Information */} +
+

+ {t('employerJobPostDetails.contact')} +

+

+ {contactInfo.name && `${contactInfo.name}: `} + {contactInfo.email} +

+
+ + {/* Ethical Tags */} + {ethicalTags.length > 0 && ( +
+

{t('employerJobPostDetails.ethicalPolicies')}

+
+ {ethicalTags.map((tag, index) => ( + + {t(`ethicalTags.tags.${getEthicalTagTranslationKey(tag)}`, tag)} + + ))} +
+
+ )} + + {/* Inclusive Opportunity */} + {jobPost.inclusiveOpportunity && ( +
+

+ {t('employerJobPostDetails.inclusiveOpportunity')} +

+

+ {t('employerJobPostDetails.inclusiveOpportunityDescription')} +

+
+ )} + + {/* Applications Received */} +
+

+ {t('employerJobPostDetails.applicationsReceived', { count: applications.length })} +

+ {applications.length === 0 ? ( +

{t('employerJobPostDetails.noApplications')}

+ ) : ( +
+ {applications.map((application) => ( + +
+
+ + + {application.applicantName + .split(' ') + .map((n) => n[0]) + .join('')} + + +
+

{application.applicantName}

+

{application.title}

+

{formatDate(application.appliedDate)}

+
+
+ +
+
+ ))} +
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/jobboard-frontend/src/pages/ForumPage.tsx b/apps/jobboard-frontend/src/modules/forum/pages/ForumPage.tsx similarity index 97% rename from apps/jobboard-frontend/src/pages/ForumPage.tsx rename to apps/jobboard-frontend/src/modules/forum/pages/ForumPage.tsx index c3f04791..2d455448 100644 --- a/apps/jobboard-frontend/src/pages/ForumPage.tsx +++ b/apps/jobboard-frontend/src/modules/forum/pages/ForumPage.tsx @@ -1,4 +1,4 @@ -import ForumPost from "@/components/forum/ForumPost"; +import ForumPost from '@modules/forum/components/forum/ForumPost'; import { useState } from "react"; const mockPost = { diff --git a/apps/jobboard-frontend/src/pages/HomePage.tsx b/apps/jobboard-frontend/src/modules/home/pages/HomePage.tsx similarity index 88% rename from apps/jobboard-frontend/src/pages/HomePage.tsx rename to apps/jobboard-frontend/src/modules/home/pages/HomePage.tsx index f7ec9319..d433dfe2 100644 --- a/apps/jobboard-frontend/src/pages/HomePage.tsx +++ b/apps/jobboard-frontend/src/modules/home/pages/HomePage.tsx @@ -1,8 +1,9 @@ -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import heroBackground from '@/assets/hero-background.jpg'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@shared/components/ui/card'; +import { Input } from '@shared/components/ui/input'; +import { useMediaQuery } from '@shared/hooks/useMediaQuery'; +import { useAuth } from '@shared/contexts/AuthContext'; import { Search, Users, @@ -17,10 +18,13 @@ import { import { type FormEvent, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { getDashboardStats, type DashboardStatsResponse } from '@/services/dashboard.service'; +import { getDashboardStats, type DashboardStatsResponse } from '@modules/employer/services/dashboard.service'; export default function HomePage() { const isMediumOrLarger = useMediaQuery('(min-width: 768px)'); + const { user, isAuthenticated } = useAuth(); + const isEmployer = user?.role === 'ROLE_EMPLOYER'; + const isJobSeeker = user?.role === 'ROLE_JOBSEEKER'; const navigate = useNavigate(); const { t } = useTranslation('common'); const [searchTerm, setSearchTerm] = useState(''); @@ -187,6 +191,49 @@ export default function HomePage() { + + {/* Role-aware quick links */} +
+ {isEmployer ? ( + <> + + + + + + ) : ( + <> + + + {isAuthenticated && isJobSeeker && ( + + )} + + + + + )} +
diff --git a/apps/jobboard-frontend/src/pages/JobDetailPage.tsx b/apps/jobboard-frontend/src/modules/jobs/pages/JobDetailPage.tsx similarity index 92% rename from apps/jobboard-frontend/src/pages/JobDetailPage.tsx rename to apps/jobboard-frontend/src/modules/jobs/pages/JobDetailPage.tsx index c6fba838..d7c62fcb 100644 --- a/apps/jobboard-frontend/src/pages/JobDetailPage.tsx +++ b/apps/jobboard-frontend/src/modules/jobs/pages/JobDetailPage.tsx @@ -1,196 +1,196 @@ -import { useEffect, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { ChevronRight, MapPin, DollarSign, Accessibility } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import CenteredLoader from '@/components/CenteredLoader'; -import { WorkplaceCard } from '@/components/workplace/WorkplaceCard'; -import type { JobPostResponse } from '@/types/api.types'; -import { getJobById } from '@/services/jobs.service'; -import { cn } from '@/lib/utils'; - -export default function JobDetailPage() { - const { id } = useParams<{ id: string }>(); - const [job, setJob] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const { t, i18n } = useTranslation('common'); - const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; - const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; - - useEffect(() => { - const fetchJob = async () => { - if (!id) return; - - try { - setIsLoading(true); - setError(null); - const jobData = await getJobById(parseInt(id, 10)); - setJob(jobData); - } catch (err) { - console.error('Error fetching job:', err); - setError('fetch_error'); - } finally { - setIsLoading(false); - } - }; - - fetchJob(); - }, [id]); - - if (isLoading) { - return ; - } - - if (error || !job) { - const errorTitle = error - ? t('jobDetail.error.title') - : t('jobDetail.error.missing'); - const errorMessage = error - ? t('jobDetail.error.fetch') - : t('jobDetail.error.description'); - - return ( -
-

- {errorTitle} -

-

- {errorMessage} -

- -
- ); - } - - // Parse contact info (might be JSON string or plain string) - let contactInfo: { name?: string; title?: string; email?: string } = {}; - try { - contactInfo = typeof job.contact === 'string' && job.contact.startsWith('{') - ? JSON.parse(job.contact) - : { email: job.contact }; - } catch { - contactInfo = { email: job.contact }; - } - - const formatSalary = (min: number, max: number) => { - return `$${(min / 1000).toFixed(0)},000 - $${(max / 1000).toFixed(0)},000`; - }; - - return ( -
- {/* Breadcrumb */} - - - {/* Main Content */} -
- -
- {/* Header Section */} -
-
-

{job.title}

-
- - {job.workplace.companyName} - - · - - - {job.remote ? t('jobCard.remote') : job.location} - - · - - - {formatSalary(job.minSalary, job.maxSalary)} - -
-
- -
- - {/* Workplace Information */} -
-

- {t('jobDetail.workplace.title')} -

- -
- - {/* Job Description */} -
-

- {t('jobDetail.description')} -

-

{job.description}

-
- - {/* Ethical Tags */} - {(job.workplace.ethicalTags.length > 0 || job.inclusiveOpportunity) && ( -
-
-

- {t('jobDetail.ethicalPolicy.title')} -

- - {/* Inclusive Opportunity Badge */} - {job.inclusiveOpportunity && ( -
- - - {t('jobDetail.inclusiveOpportunity')} - -
- )} - - {/* Ethical Tags */} - {job.workplace.ethicalTags.length > 0 && ( -
- {job.workplace.ethicalTags.map((tag, index) => ( - - {tag.trim()} - - ))} -
- )} -
-
- )} - - {/* Contact Information */} -
-

- {t('jobDetail.contact.title')} -

-
-

{contactInfo.name}

-

{contactInfo.title}

- - {contactInfo.email} - -
-
-
-
-
-
- ); -} +import { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ChevronRight, MapPin, DollarSign, Accessibility } from 'lucide-react'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import { WorkplaceCard } from '@modules/workplace/components/workplace/WorkplaceCard'; +import type { JobPostResponse } from '@shared/types/api.types'; +import { getJobById } from '@modules/jobs/services/jobs.service'; +import { cn } from '@shared/lib/utils'; + +export default function JobDetailPage() { + const { id } = useParams<{ id: string }>(); + const [job, setJob] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const { t, i18n } = useTranslation('common'); + const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; + const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; + + useEffect(() => { + const fetchJob = async () => { + if (!id) return; + + try { + setIsLoading(true); + setError(null); + const jobData = await getJobById(parseInt(id, 10)); + setJob(jobData); + } catch (err) { + console.error('Error fetching job:', err); + setError('fetch_error'); + } finally { + setIsLoading(false); + } + }; + + fetchJob(); + }, [id]); + + if (isLoading) { + return ; + } + + if (error || !job) { + const errorTitle = error + ? t('jobDetail.error.title') + : t('jobDetail.error.missing'); + const errorMessage = error + ? t('jobDetail.error.fetch') + : t('jobDetail.error.description'); + + return ( +
+

+ {errorTitle} +

+

+ {errorMessage} +

+ +
+ ); + } + + // Parse contact info (might be JSON string or plain string) + let contactInfo: { name?: string; title?: string; email?: string } = {}; + try { + contactInfo = typeof job.contact === 'string' && job.contact.startsWith('{') + ? JSON.parse(job.contact) + : { email: job.contact }; + } catch { + contactInfo = { email: job.contact }; + } + + const formatSalary = (min: number, max: number) => { + return `$${(min / 1000).toFixed(0)},000 - $${(max / 1000).toFixed(0)},000`; + }; + + return ( +
+ {/* Breadcrumb */} + + + {/* Main Content */} +
+ +
+ {/* Header Section */} +
+
+

{job.title}

+
+ + {job.workplace.companyName} + + · + + + {job.remote ? t('jobCard.remote') : job.location} + + · + + + {formatSalary(job.minSalary, job.maxSalary)} + +
+
+ +
+ + {/* Workplace Information */} +
+

+ {t('jobDetail.workplace.title')} +

+ +
+ + {/* Job Description */} +
+

+ {t('jobDetail.description')} +

+

{job.description}

+
+ + {/* Ethical Tags */} + {(job.workplace.ethicalTags.length > 0 || job.inclusiveOpportunity) && ( +
+
+

+ {t('jobDetail.ethicalPolicy.title')} +

+ + {/* Inclusive Opportunity Badge */} + {job.inclusiveOpportunity && ( +
+ + + {t('jobDetail.inclusiveOpportunity')} + +
+ )} + + {/* Ethical Tags */} + {job.workplace.ethicalTags.length > 0 && ( +
+ {job.workplace.ethicalTags.map((tag, index) => ( + + {tag.trim()} + + ))} +
+ )} +
+
+ )} + + {/* Contact Information */} +
+

+ {t('jobDetail.contact.title')} +

+
+

{contactInfo.name}

+

{contactInfo.title}

+ + {contactInfo.email} + +
+
+
+
+
+
+ ); +} diff --git a/apps/jobboard-frontend/src/pages/JobsPage.tsx b/apps/jobboard-frontend/src/modules/jobs/pages/JobsPage.tsx similarity index 93% rename from apps/jobboard-frontend/src/pages/JobsPage.tsx rename to apps/jobboard-frontend/src/modules/jobs/pages/JobsPage.tsx index ed6fee7d..d85bc23d 100644 --- a/apps/jobboard-frontend/src/pages/JobsPage.tsx +++ b/apps/jobboard-frontend/src/modules/jobs/pages/JobsPage.tsx @@ -1,419 +1,419 @@ -import { useEffect, useMemo, useState } from 'react'; -import { Search, X, AlertCircle } from 'lucide-react'; -import { useSearchParams, Link } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { JobCard } from '@/components/jobs/JobCard'; -import { JobFilters } from '@/components/jobs/JobFilters'; -import { MobileJobFilters } from '@/components/jobs/MobileJobFilters'; -import { Button } from '@/components/ui/button'; -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/pagination'; -import { Input } from '@/components/ui/input'; -import { cn } from '@/lib/utils'; -import { type Job, type JobType } from '@/types/job'; -import { useFilters } from '@/hooks/useFilters'; -import { getJobs } from '@/services/jobs.service'; -import type { JobPostResponse } from '@/types/api.types'; -import CenteredLoader from '@/components/CenteredLoader'; -import { AxiosError } from 'axios'; -import { Card, CardContent } from '@/components/ui/card'; - -const ITEMS_PER_PAGE = 10; - -/** - * Convert API JobPostResponse to Job type for JobCard component - */ -function convertJobPostToJob(jobPost: JobPostResponse): Job { - // Note: API doesn't provide job type field, using default value - // This can be enhanced if the API adds job type information in the future - const type: JobType[] = ['Full-time']; - - return { - id: jobPost.id.toString(), - title: jobPost.title, - workplace: jobPost.workplace || { - id: 0, - companyName: 'Unknown Company', - sector: 'Unknown', - location: 'Unknown', - shortDescription: undefined, - overallAvg: 0, - ethicalTags: [], - ethicalAverages: {}, - }, - location: jobPost.remote ? 'Remote' : jobPost.location, - type, - minSalary: Math.floor(jobPost.minSalary / 1000), // Convert to 'k' format - maxSalary: Math.floor(jobPost.maxSalary / 1000), - logoUrl: jobPost.workplace?.imageUrl, // Use workplace logo if available - inclusiveOpportunity: jobPost.inclusiveOpportunity, - }; -} - -export default function JobsPage() { - const [searchParams, setSearchParams] = useSearchParams(); - const searchParamValue = searchParams.get('search') ?? ''; - const [searchInput, setSearchInput] = useState(searchParamValue); - const [searchFilter, setSearchFilter] = useState(searchParamValue); - const [currentPage, setCurrentPage] = useState(1); - const [isMobileFiltersOpen, setIsMobileFiltersOpen] = useState(false); - const [jobs, setJobs] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [isAuthError, setIsAuthError] = useState(false); - const { t, i18n } = useTranslation('common'); - const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; - const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; - - const { - selectedEthicalTags, - salaryRange, - companyNameFilter, - isRemoteOnly, - isDisabilityInclusive, - resetFilters - } = useFilters(); - - // Create derived keys for useEffect dependencies (to prevent unnecessary re-renders) - const ethicalTagsKey = selectedEthicalTags.join(','); - const salaryKey = `${salaryRange[0]}-${salaryRange[1]}`; - - useEffect(() => { - setSearchInput((prev) => (prev === searchParamValue ? prev : searchParamValue)); - setSearchFilter((prev) => { - if (prev === searchParamValue) { - return prev; - } - setCurrentPage(1); - return searchParamValue; - }); - }, [searchParamValue]); - - // Fetch jobs from API - useEffect(() => { - const fetchJobs = async () => { - try { - setIsLoading(true); - setError(null); - setIsAuthError(false); - - // Build filter params based on user selections - const filters = { - title: searchFilter || undefined, - company: companyNameFilter || undefined, - ethicalTags: selectedEthicalTags.length > 0 ? selectedEthicalTags : undefined, - minSalary: salaryRange[0] * 1000, // Convert back to actual salary - maxSalary: salaryRange[1] * 1000, - isRemote: isRemoteOnly ? true : undefined, - inclusiveOpportunity: isDisabilityInclusive ? true : undefined, - }; - - const jobPosts = await getJobs(filters); - const convertedJobs = jobPosts.map(convertJobPostToJob); - setJobs(convertedJobs); - } catch (err) { - console.error('Error fetching jobs:', err); - - // Check if it's a 401 authentication error - if (err instanceof AxiosError && err.response?.status === 401) { - setIsAuthError(true); - setError('auth_error'); - } else { - setError('fetch_error'); - } - } finally { - setIsLoading(false); - } - }; - - fetchJobs(); - }, [searchFilter, ethicalTagsKey, salaryKey, companyNameFilter, isRemoteOnly, isDisabilityInclusive]); - - // Jobs are now filtered on the server, so we just use them directly - const filteredJobs = jobs; - - const totalPages = Math.max(1, Math.ceil(filteredJobs.length / ITEMS_PER_PAGE)); - - useEffect(() => { - if (currentPage > totalPages) { - setCurrentPage(totalPages); - } - }, [totalPages, currentPage]); - - const paginatedJobs = useMemo(() => { - const start = (currentPage - 1) * ITEMS_PER_PAGE; - const end = start + ITEMS_PER_PAGE; - return filteredJobs.slice(start, end); - }, [filteredJobs, currentPage]); - - const updateSearchParam = (value: string) => { - const next = new URLSearchParams(searchParams); - if (value) { - next.set('search', value); - } else { - next.delete('search'); - } - setSearchParams(next, { replace: true }); - }; - - const handleSearchSubmit = () => { - const trimmed = searchInput.trim(); - setSearchInput(trimmed); - updateSearchParam(trimmed); - }; - - const handleClearSearch = () => { - setSearchInput(''); - updateSearchParam(''); - }; - - const handleResetFilters = () => { - resetFilters(); - handleClearSearch(); - setCurrentPage(1); - }; - - const jobCount = filteredJobs.length; - - const paginationNumbers = useMemo(() => { - const pages: number[] = []; - - if (totalPages <= 7) { - for (let page = 1; page <= totalPages; page += 1) { - pages.push(page); - } - return pages; - } - - pages.push(1, 2, 3); - - if (currentPage > 5) { - pages.push(-1); - } - - const start = Math.max(4, currentPage - 1); - const end = Math.min(totalPages - 1, currentPage + 1); - - for (let page = start; page <= end; page += 1) { - if (!pages.includes(page)) { - pages.push(page); - } - } - - if (currentPage < totalPages - 3) { - pages.push(-2); - } - - if (!pages.includes(totalPages)) { - pages.push(totalPages); - } - - return pages; - }, [currentPage, totalPages]); - - return ( -
-
- {/* Filters */} - - - {/* Content */} -
-
-
- {/* Search Input*/} -
- - - - setSearchInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - handleSearchSubmit(); - } - }} - placeholder={t('jobs.searchPlaceholder')} - id="search-input" - className="h-14 rounded-lg border border-border pl-11 pr-12 bg-card text-lg" - aria-label={t('jobs.searchAria')} - /> - {searchInput && ( - - )} -
- } - /> -
-
- -
-
- {isLoading ? ( - - ) : error ? ( - isAuthError ? ( - - -
- -
-

- {t('jobs.authRequired.title')} -

-

- {t('jobs.authRequired.description')} -

-

- {t('jobs.authRequired.invitation')} -

-
-
-
- - -
-
-
- ) : ( -
-

{t('jobs.error')}

- -
- ) - ) : ( - <> -
-

- {t('jobs.results', { count: jobCount })} -

- - {t('jobs.resultsDescription', { count: jobCount })} - -
- - {/* Job Cards */} - {jobCount === 0 ? ( -
-

- {t('jobs.noResults')} -

- -
- ) : ( -
- {paginatedJobs.map((job) => ( - - ))} -
- )} - - )} - - {/* Pagination */} - {!isLoading && !error && jobCount > 0 && ( - - - - { - event.preventDefault(); - setCurrentPage((page) => Math.max(1, page - 1)); - }} - aria-disabled={currentPage === 1} - className={cn( - currentPage === 1 && 'pointer-events-none opacity-50', - isRtl && 'rotate-180' - )} - /> - - {paginationNumbers.map((page, index) => { - if (page < 0) { - return ( - - - - ); - } - return ( - - { - event.preventDefault(); - setCurrentPage(page); - }} - className={cn( - 'size-10 rounded-lg text-sm', - page === currentPage - ? 'border-primary text-primary' - : 'text-muted-foreground hover:text-primary', - )} - > - {page} - - - ); - })} - - { - event.preventDefault(); - setCurrentPage((page) => Math.min(totalPages, page + 1)); - }} - aria-disabled={currentPage === totalPages} - className={cn( - currentPage === totalPages && 'pointer-events-none opacity-50', - isRtl && 'rotate-180' - )} - /> - - - - )} -
-
-
-
-
- ); -} +import { useEffect, useMemo, useState } from 'react'; +import { Search, X, AlertCircle } from 'lucide-react'; +import { useSearchParams, Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { JobCard } from '@modules/jobs/components/jobs/JobCard'; +import { JobFilters } from '@modules/jobs/components/jobs/JobFilters'; +import { MobileJobFilters } from '@modules/jobs/components/jobs/MobileJobFilters'; +import { Button } from '@shared/components/ui/button'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@shared/components/ui/pagination'; +import { Input } from '@shared/components/ui/input'; +import { cn } from '@shared/lib/utils'; +import { type Job, type JobType } from '@shared/types/job'; +import { useFilters } from '@shared/hooks/useFilters'; +import { getJobs } from '@modules/jobs/services/jobs.service'; +import type { JobPostResponse } from '@shared/types/api.types'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import { AxiosError } from 'axios'; +import { Card, CardContent } from '@shared/components/ui/card'; + +const ITEMS_PER_PAGE = 10; + +/** + * Convert API JobPostResponse to Job type for JobCard component + */ +function convertJobPostToJob(jobPost: JobPostResponse): Job { + // Note: API doesn't provide job type field, using default value + // This can be enhanced if the API adds job type information in the future + const type: JobType[] = ['Full-time']; + + return { + id: jobPost.id.toString(), + title: jobPost.title, + workplace: jobPost.workplace || { + id: 0, + companyName: 'Unknown Company', + sector: 'Unknown', + location: 'Unknown', + shortDescription: undefined, + overallAvg: 0, + ethicalTags: [], + ethicalAverages: {}, + }, + location: jobPost.remote ? 'Remote' : jobPost.location, + type, + minSalary: Math.floor(jobPost.minSalary / 1000), // Convert to 'k' format + maxSalary: Math.floor(jobPost.maxSalary / 1000), + logoUrl: jobPost.workplace?.imageUrl, // Use workplace logo if available + inclusiveOpportunity: jobPost.inclusiveOpportunity, + }; +} + +export default function JobsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const searchParamValue = searchParams.get('search') ?? ''; + const [searchInput, setSearchInput] = useState(searchParamValue); + const [searchFilter, setSearchFilter] = useState(searchParamValue); + const [currentPage, setCurrentPage] = useState(1); + const [isMobileFiltersOpen, setIsMobileFiltersOpen] = useState(false); + const [jobs, setJobs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isAuthError, setIsAuthError] = useState(false); + const { t, i18n } = useTranslation('common'); + const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; + const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; + + const { + selectedEthicalTags, + salaryRange, + companyNameFilter, + isRemoteOnly, + isDisabilityInclusive, + resetFilters + } = useFilters(); + + // Create derived keys for useEffect dependencies (to prevent unnecessary re-renders) + const ethicalTagsKey = selectedEthicalTags.join(','); + const salaryKey = `${salaryRange[0]}-${salaryRange[1]}`; + + useEffect(() => { + setSearchInput((prev) => (prev === searchParamValue ? prev : searchParamValue)); + setSearchFilter((prev) => { + if (prev === searchParamValue) { + return prev; + } + setCurrentPage(1); + return searchParamValue; + }); + }, [searchParamValue]); + + // Fetch jobs from API + useEffect(() => { + const fetchJobs = async () => { + try { + setIsLoading(true); + setError(null); + setIsAuthError(false); + + // Build filter params based on user selections + const filters = { + title: searchFilter || undefined, + company: companyNameFilter || undefined, + ethicalTags: selectedEthicalTags.length > 0 ? selectedEthicalTags : undefined, + minSalary: salaryRange[0] * 1000, // Convert back to actual salary + maxSalary: salaryRange[1] * 1000, + isRemote: isRemoteOnly ? true : undefined, + inclusiveOpportunity: isDisabilityInclusive ? true : undefined, + }; + + const jobPosts = await getJobs(filters); + const convertedJobs = jobPosts.map(convertJobPostToJob); + setJobs(convertedJobs); + } catch (err) { + console.error('Error fetching jobs:', err); + + // Check if it's a 401 authentication error + if (err instanceof AxiosError && err.response?.status === 401) { + setIsAuthError(true); + setError('auth_error'); + } else { + setError('fetch_error'); + } + } finally { + setIsLoading(false); + } + }; + + fetchJobs(); + }, [searchFilter, ethicalTagsKey, salaryKey, companyNameFilter, isRemoteOnly, isDisabilityInclusive]); + + // Jobs are now filtered on the server, so we just use them directly + const filteredJobs = jobs; + + const totalPages = Math.max(1, Math.ceil(filteredJobs.length / ITEMS_PER_PAGE)); + + useEffect(() => { + if (currentPage > totalPages) { + setCurrentPage(totalPages); + } + }, [totalPages, currentPage]); + + const paginatedJobs = useMemo(() => { + const start = (currentPage - 1) * ITEMS_PER_PAGE; + const end = start + ITEMS_PER_PAGE; + return filteredJobs.slice(start, end); + }, [filteredJobs, currentPage]); + + const updateSearchParam = (value: string) => { + const next = new URLSearchParams(searchParams); + if (value) { + next.set('search', value); + } else { + next.delete('search'); + } + setSearchParams(next, { replace: true }); + }; + + const handleSearchSubmit = () => { + const trimmed = searchInput.trim(); + setSearchInput(trimmed); + updateSearchParam(trimmed); + }; + + const handleClearSearch = () => { + setSearchInput(''); + updateSearchParam(''); + }; + + const handleResetFilters = () => { + resetFilters(); + handleClearSearch(); + setCurrentPage(1); + }; + + const jobCount = filteredJobs.length; + + const paginationNumbers = useMemo(() => { + const pages: number[] = []; + + if (totalPages <= 7) { + for (let page = 1; page <= totalPages; page += 1) { + pages.push(page); + } + return pages; + } + + pages.push(1, 2, 3); + + if (currentPage > 5) { + pages.push(-1); + } + + const start = Math.max(4, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let page = start; page <= end; page += 1) { + if (!pages.includes(page)) { + pages.push(page); + } + } + + if (currentPage < totalPages - 3) { + pages.push(-2); + } + + if (!pages.includes(totalPages)) { + pages.push(totalPages); + } + + return pages; + }, [currentPage, totalPages]); + + return ( +
+
+ {/* Filters */} + + + {/* Content */} +
+
+
+ {/* Search Input*/} +
+ + + + setSearchInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSearchSubmit(); + } + }} + placeholder={t('jobs.searchPlaceholder')} + id="search-input" + className="h-14 rounded-lg border border-border pl-11 pr-12 bg-card text-lg" + aria-label={t('jobs.searchAria')} + /> + {searchInput && ( + + )} +
+ } + /> +
+
+ +
+
+ {isLoading ? ( + + ) : error ? ( + isAuthError ? ( + + +
+ +
+

+ {t('jobs.authRequired.title')} +

+

+ {t('jobs.authRequired.description')} +

+

+ {t('jobs.authRequired.invitation')} +

+
+
+
+ + +
+
+
+ ) : ( +
+

{t('jobs.error')}

+ +
+ ) + ) : ( + <> +
+

+ {t('jobs.results', { count: jobCount })} +

+ + {t('jobs.resultsDescription', { count: jobCount })} + +
+ + {/* Job Cards */} + {jobCount === 0 ? ( +
+

+ {t('jobs.noResults')} +

+ +
+ ) : ( +
+ {paginatedJobs.map((job) => ( + + ))} +
+ )} + + )} + + {/* Pagination */} + {!isLoading && !error && jobCount > 0 && ( + + + + { + event.preventDefault(); + setCurrentPage((page) => Math.max(1, page - 1)); + }} + aria-disabled={currentPage === 1} + className={cn( + currentPage === 1 && 'pointer-events-none opacity-50', + isRtl && 'rotate-180' + )} + /> + + {paginationNumbers.map((page, index) => { + if (page < 0) { + return ( + + + + ); + } + return ( + + { + event.preventDefault(); + setCurrentPage(page); + }} + className={cn( + 'size-10 rounded-lg text-sm', + page === currentPage + ? 'border-primary text-primary' + : 'text-muted-foreground hover:text-primary', + )} + > + {page} + + + ); + })} + + { + event.preventDefault(); + setCurrentPage((page) => Math.min(totalPages, page + 1)); + }} + aria-disabled={currentPage === totalPages} + className={cn( + currentPage === totalPages && 'pointer-events-none opacity-50', + isRtl && 'rotate-180' + )} + /> + + + + )} +
+
+
+
+
+ ); +} diff --git a/apps/jobboard-frontend/src/pages/CreateMentorProfilePage.tsx b/apps/jobboard-frontend/src/modules/mentorship/pages/CreateMentorProfilePage.tsx similarity index 95% rename from apps/jobboard-frontend/src/pages/CreateMentorProfilePage.tsx rename to apps/jobboard-frontend/src/modules/mentorship/pages/CreateMentorProfilePage.tsx index 96baecf7..25914b99 100644 --- a/apps/jobboard-frontend/src/pages/CreateMentorProfilePage.tsx +++ b/apps/jobboard-frontend/src/modules/mentorship/pages/CreateMentorProfilePage.tsx @@ -3,17 +3,17 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { X, AlertCircle } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { createMentorProfile, updateMentorProfile, getMentorProfile } from '@/services/mentorship.service'; -import { useAuth } from '@/contexts/AuthContext'; -import type { CreateMentorProfileDTO, UpdateMentorProfileDTO } from '@/types/api.types'; -import { profileService } from '@/services/profile.service'; -import type { Profile } from '@/types/profile.types'; -import CenteredLoader from '@/components/CenteredLoader'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Label } from '@shared/components/ui/label'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { createMentorProfile, updateMentorProfile, getMentorProfile } from '@modules/mentorship/services/mentorship.service'; +import { useAuth } from '@shared/contexts/AuthContext'; +import type { CreateMentorProfileDTO, UpdateMentorProfileDTO } from '@shared/types/api.types'; +import { profileService } from '@modules/profile/services/profile.service'; +import type { Profile } from '@shared/types/profile.types'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; type MentorProfileFormData = { expertise: string[]; // Mentor'a özel expertise (skills'ten farklı) @@ -253,7 +253,7 @@ export default function CreateMentorProfilePage() { // Navigate to view profile page if (user?.id) { try { - const { getMentorProfile } = await import('@/services/mentorship.service'); + const { getMentorProfile } = await import('@modules/mentorship/services/mentorship.service'); const profile = await getMentorProfile(user.id); if (profile) { navigate(`/mentorship/${profile.id}`); diff --git a/apps/jobboard-frontend/src/pages/MentorProfilePage.tsx b/apps/jobboard-frontend/src/modules/mentorship/pages/MentorProfilePage.tsx similarity index 97% rename from apps/jobboard-frontend/src/pages/MentorProfilePage.tsx rename to apps/jobboard-frontend/src/modules/mentorship/pages/MentorProfilePage.tsx index dc3917df..3221b8ee 100644 --- a/apps/jobboard-frontend/src/pages/MentorProfilePage.tsx +++ b/apps/jobboard-frontend/src/modules/mentorship/pages/MentorProfilePage.tsx @@ -1,22 +1,22 @@ import { useParams, useNavigate, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useEffect, useState } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; +import { Button } from '@shared/components/ui/button'; +import { Badge } from '@shared/components/ui/badge'; import { Star, MapPin, Award, GraduationCap, Briefcase, Edit, MessageCircle } from 'lucide-react'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Separator } from '@/components/ui/separator'; -import type { Mentor, MentorshipReview } from '@/types/mentor'; -import { getMentorProfile, getMentorMentorshipRequests, getMenteeMentorships, createMentorshipRequest, completeMentorship } from '@/services/mentorship.service'; -import type { MentorshipDetailsDTO } from '@/types/api.types'; -import { convertMentorProfileToMentor, convertMentorReviewToMentorshipReview } from '@/utils/mentorship.utils'; -import { profileService } from '@/services/profile.service'; -import type { PublicProfile } from '@/types/profile.types'; -import { useAuth } from '@/contexts/AuthContext'; -import CenteredLoader from '@/components/CenteredLoader'; -import CenteredError from '@/components/CenteredError'; -import type { MentorshipRequestDTO } from '@/types/api.types'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; +import { Separator } from '@shared/components/ui/separator'; +import type { Mentor, MentorshipReview } from '@shared/types/mentor'; +import { getMentorProfile, getMentorMentorshipRequests, getMenteeMentorships, createMentorshipRequest, completeMentorship } from '@modules/mentorship/services/mentorship.service'; +import type { MentorshipDetailsDTO } from '@shared/types/api.types'; +import { convertMentorProfileToMentor, convertMentorReviewToMentorshipReview } from '@shared/utils/mentorship.utils'; +import { profileService } from '@modules/profile/services/profile.service'; +import type { PublicProfile } from '@shared/types/profile.types'; +import { useAuth } from '@shared/contexts/AuthContext'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import CenteredError from '@shared/components/common/CenteredError'; +import type { MentorshipRequestDTO } from '@shared/types/api.types'; import { toast } from 'react-toastify'; const MentorProfilePage = () => { diff --git a/apps/jobboard-frontend/src/pages/MentorRequestsPage.tsx b/apps/jobboard-frontend/src/modules/mentorship/pages/MentorRequestsPage.tsx similarity index 94% rename from apps/jobboard-frontend/src/pages/MentorRequestsPage.tsx rename to apps/jobboard-frontend/src/modules/mentorship/pages/MentorRequestsPage.tsx index 7de0d1ec..971a8d55 100644 --- a/apps/jobboard-frontend/src/pages/MentorRequestsPage.tsx +++ b/apps/jobboard-frontend/src/modules/mentorship/pages/MentorRequestsPage.tsx @@ -1,19 +1,19 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Card, CardContent, CardHeader } from '@shared/components/ui/card'; +import { Button } from '@shared/components/ui/button'; +import { Badge } from '@shared/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared/components/ui/tabs'; import { Clock, CheckCircle, XCircle, User, Calendar } from 'lucide-react'; -import type { MentorshipRequestDTO } from '@/types/api.types'; -import { useAuth } from '@/contexts/AuthContext'; -import { getMentorMentorshipRequests, respondToMentorshipRequest } from '@/services/mentorship.service'; -import { profileService } from '@/services/profile.service'; -import type { PublicProfile } from '@/types/profile.types'; -import CenteredLoader from '@/components/CenteredLoader'; -import CenteredError from '@/components/CenteredError'; +import type { MentorshipRequestDTO } from '@shared/types/api.types'; +import { useAuth } from '@shared/contexts/AuthContext'; +import { getMentorMentorshipRequests, respondToMentorshipRequest } from '@modules/mentorship/services/mentorship.service'; +import { profileService } from '@modules/profile/services/profile.service'; +import type { PublicProfile } from '@shared/types/profile.types'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import CenteredError from '@shared/components/common/CenteredError'; import { toast } from 'react-toastify'; const getStatusIcon = (status: string) => { diff --git a/apps/jobboard-frontend/src/pages/MentorshipPage.tsx b/apps/jobboard-frontend/src/modules/mentorship/pages/MentorshipPage.tsx similarity index 92% rename from apps/jobboard-frontend/src/pages/MentorshipPage.tsx rename to apps/jobboard-frontend/src/modules/mentorship/pages/MentorshipPage.tsx index bf95e770..5622bc29 100644 --- a/apps/jobboard-frontend/src/pages/MentorshipPage.tsx +++ b/apps/jobboard-frontend/src/modules/mentorship/pages/MentorshipPage.tsx @@ -2,19 +2,18 @@ import { useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { AlertCircle, Search, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import MentorCard from "@/components/mentorship/MentorCard"; -import { type Mentor } from "@/types/mentor"; -import { useAuth } from '@/contexts/AuthContext'; -import { cn } from '@/lib/utils'; -import { getMentors, getMentorProfile } from '@/services/mentorship.service'; -import { getMenteeMentorships } from '@/services/mentorship.service'; -import { convertMentorProfileToMentor } from '@/utils/mentorship.utils'; -import { profileService } from '@/services/profile.service'; -import CenteredLoader from '@/components/CenteredLoader'; -import CenteredError from '@/components/CenteredError'; +import { Button } from '@shared/components/ui/button'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { Input } from '@shared/components/ui/input'; +import MentorCard from '@modules/mentorship/components/mentorship/MentorCard'; +import { type Mentor } from '@shared/types/mentor'; +import { useAuth } from '@shared/contexts/AuthContext'; +import { cn } from '@shared/lib/utils'; +import { getMentors, getMentorProfile, getMenteeMentorships } from '@modules/mentorship/services/mentorship.service'; +import { convertMentorProfileToMentor } from '@shared/utils/mentorship.utils'; +import { profileService } from '@modules/profile/services/profile.service'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import CenteredError from '@shared/components/common/CenteredError'; const MentorshipPage = () => { const { t } = useTranslation('common'); diff --git a/apps/jobboard-frontend/src/pages/MentorshipRequestPage.tsx b/apps/jobboard-frontend/src/modules/mentorship/pages/MentorshipRequestPage.tsx similarity index 96% rename from apps/jobboard-frontend/src/pages/MentorshipRequestPage.tsx rename to apps/jobboard-frontend/src/modules/mentorship/pages/MentorshipRequestPage.tsx index 957f53dd..a1a43839 100644 --- a/apps/jobboard-frontend/src/pages/MentorshipRequestPage.tsx +++ b/apps/jobboard-frontend/src/modules/mentorship/pages/MentorshipRequestPage.tsx @@ -1,21 +1,21 @@ import { useState, useEffect, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Checkbox } from '@/components/ui/checkbox'; +import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Textarea } from '@shared/components/ui/textarea'; +import { Label } from '@shared/components/ui/label'; +import { Badge } from '@shared/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; +import { Checkbox } from '@shared/components/ui/checkbox'; import { Star, ArrowLeft, Send, Clock, Users, AlertCircle } from 'lucide-react'; -import type { Mentor, Mentorship } from '@/types/mentor'; -import { useAuth } from '@/contexts/AuthContext'; -import { getMentorProfile, createMentorshipRequest, getMenteeMentorships } from '@/services/mentorship.service'; -import { convertMentorProfileToMentor } from '@/utils/mentorship.utils'; -import CenteredLoader from '@/components/CenteredLoader'; -import CenteredError from '@/components/CenteredError'; +import type { Mentor, Mentorship } from '@shared/types/mentor'; +import { useAuth } from '@shared/contexts/AuthContext'; +import { getMentorProfile, createMentorshipRequest, getMenteeMentorships } from '@modules/mentorship/services/mentorship.service'; +import { convertMentorProfileToMentor } from '@shared/utils/mentorship.utils'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import CenteredError from '@shared/components/common/CenteredError'; import { toast } from 'react-toastify'; const MentorshipRequestPage = () => { diff --git a/apps/jobboard-frontend/src/pages/MyMentorshipsPage.tsx b/apps/jobboard-frontend/src/modules/mentorship/pages/MyMentorshipsPage.tsx similarity index 95% rename from apps/jobboard-frontend/src/pages/MyMentorshipsPage.tsx rename to apps/jobboard-frontend/src/modules/mentorship/pages/MyMentorshipsPage.tsx index fddd2768..8374fd8a 100644 --- a/apps/jobboard-frontend/src/pages/MyMentorshipsPage.tsx +++ b/apps/jobboard-frontend/src/modules/mentorship/pages/MyMentorshipsPage.tsx @@ -1,23 +1,23 @@ import { useState, useEffect } from 'react'; import { useLocation, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; -import { Textarea } from '@/components/ui/textarea'; -import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader } from '@shared/components/ui/card'; +import { Button } from '@shared/components/ui/button'; +import { Badge } from '@shared/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared/components/ui/tabs'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@shared/components/ui/dialog'; +import { Textarea } from '@shared/components/ui/textarea'; +import { Label } from '@shared/components/ui/label'; import { Clock, CheckCircle, XCircle, MessageCircle, Star, Calendar, FileText } from 'lucide-react'; -import type { Mentorship, MentorshipStatus } from '@/types/mentor'; -import { useAuth } from '@/contexts/AuthContext'; -import { getMenteeMentorships, rateMentor } from '@/services/mentorship.service'; -import type { CreateRatingDTO } from '@/types/api.types'; -import { convertMentorshipDetailsToMentorship } from '@/utils/mentorship.utils'; -import { profileService } from '@/services/profile.service'; -import CenteredLoader from '@/components/CenteredLoader'; -import CenteredError from '@/components/CenteredError'; +import type { Mentorship, MentorshipStatus } from '@shared/types/mentor'; +import { useAuth } from '@shared/contexts/AuthContext'; +import { getMenteeMentorships, rateMentor } from '@modules/mentorship/services/mentorship.service'; +import type { CreateRatingDTO } from '@shared/types/api.types'; +import { convertMentorshipDetailsToMentorship } from '@shared/utils/mentorship.utils'; +import { profileService } from '@modules/profile/services/profile.service'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import CenteredError from '@shared/components/common/CenteredError'; import { toast } from 'react-toastify'; const getStatusIcon = (status: MentorshipStatus) => { diff --git a/apps/jobboard-frontend/src/pages/ProfilePage.tsx b/apps/jobboard-frontend/src/modules/profile/pages/ProfilePage.tsx similarity index 94% rename from apps/jobboard-frontend/src/pages/ProfilePage.tsx rename to apps/jobboard-frontend/src/modules/profile/pages/ProfilePage.tsx index bcdbcc14..b551b9bd 100644 --- a/apps/jobboard-frontend/src/pages/ProfilePage.tsx +++ b/apps/jobboard-frontend/src/modules/profile/pages/ProfilePage.tsx @@ -1,14 +1,14 @@ import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { ProfileHeader } from '@/components/profile/ProfileHeader'; -import { AboutSection } from '@/components/profile/AboutSection'; -import { ExperienceSection } from '@/components/profile/ExperienceSection'; -import { EducationSection } from '@/components/profile/EducationSection'; -import { SkillsSection } from '@/components/profile/SkillsSection'; -import { InterestsSection } from '@/components/profile/InterestsSection'; -import { ActivityTab } from '@/components/profile/ActivityTab'; -import { PostsTab } from '@/components/profile/PostsTab'; +import { ProfileHeader } from '@modules/profile/components/profile/ProfileHeader'; +import { AboutSection } from '@modules/profile/components/profile/AboutSection'; +import { ExperienceSection } from '@modules/profile/components/profile/ExperienceSection'; +import { EducationSection } from '@modules/profile/components/profile/EducationSection'; +import { SkillsSection } from '@modules/profile/components/profile/SkillsSection'; +import { InterestsSection } from '@modules/profile/components/profile/InterestsSection'; +import { ActivityTab } from '@modules/profile/components/profile/ActivityTab'; +import { PostsTab } from '@modules/profile/components/profile/PostsTab'; import { EditBioModal, ExperienceModal, @@ -17,11 +17,11 @@ import { InterestModal, CreateProfileModal, ImageUploadModal, -} from '@/components/profile/ProfileEditModals'; -import { DeleteAccountModal } from '@/components/profile/DeleteAccountModal'; -import type { Profile, Activity, Post, Experience, Education, Skill, Interest } from '@/types/profile.types'; -import { profileService } from '@/services/profile.service'; -import CenteredLoader from '@/components/CenteredLoader'; +} from '@modules/profile/components/profile/ProfileEditModals'; +import { DeleteAccountModal } from '@modules/profile/components/profile/DeleteAccountModal'; +import type { Profile, Activity, Post, Experience, Education, Skill, Interest } from '@shared/types/profile.types'; +import { profileService } from '@modules/profile/services/profile.service'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; type ModalKey = 'bio' | 'experience' | 'education' | 'skill' | 'interest'; diff --git a/apps/jobboard-frontend/src/pages/PublicProfilePage.tsx b/apps/jobboard-frontend/src/modules/profile/pages/PublicProfilePage.tsx similarity index 91% rename from apps/jobboard-frontend/src/pages/PublicProfilePage.tsx rename to apps/jobboard-frontend/src/modules/profile/pages/PublicProfilePage.tsx index 2d3006e8..ada6acf7 100644 --- a/apps/jobboard-frontend/src/pages/PublicProfilePage.tsx +++ b/apps/jobboard-frontend/src/modules/profile/pages/PublicProfilePage.tsx @@ -2,15 +2,15 @@ import { useState, useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { AboutSection } from '@/components/profile/AboutSection'; -import { ExperienceSection } from '@/components/profile/ExperienceSection'; -import { EducationSection } from '@/components/profile/EducationSection'; -import { ActivityTab } from '@/components/profile/ActivityTab'; -import { PostsTab } from '@/components/profile/PostsTab'; -import type { PublicProfile, Activity, Post } from '@/types/profile.types'; -import { profileService } from '@/services/profile.service'; -import CenteredLoader from '@/components/CenteredLoader'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; +import { AboutSection } from '@modules/profile/components/profile/AboutSection'; +import { ExperienceSection } from '@modules/profile/components/profile/ExperienceSection'; +import { EducationSection } from '@modules/profile/components/profile/EducationSection'; +import { ActivityTab } from '@modules/profile/components/profile/ActivityTab'; +import { PostsTab } from '@modules/profile/components/profile/PostsTab'; +import type { PublicProfile, Activity, Post } from '@shared/types/profile.types'; +import { profileService } from '@modules/profile/services/profile.service'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; export default function PublicProfilePage() { const { userId } = useParams<{ userId: string }>(); @@ -230,4 +230,4 @@ export default function PublicProfilePage() { ); -} \ No newline at end of file +} diff --git a/apps/jobboard-frontend/src/pages/ResumeReviewPage.tsx b/apps/jobboard-frontend/src/modules/resumeReview/pages/ResumeReviewPage.tsx similarity index 97% rename from apps/jobboard-frontend/src/pages/ResumeReviewPage.tsx rename to apps/jobboard-frontend/src/modules/resumeReview/pages/ResumeReviewPage.tsx index e51646ca..408576bf 100644 --- a/apps/jobboard-frontend/src/pages/ResumeReviewPage.tsx +++ b/apps/jobboard-frontend/src/modules/resumeReview/pages/ResumeReviewPage.tsx @@ -2,19 +2,19 @@ import { useState, useEffect, useRef } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Download, FileText, MessageCircle, ArrowLeft, Upload, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import CenteredLoader from '@/components/CenteredLoader'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Label } from '@shared/components/ui/label'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; import { getResumeReview, getResumeFileUrl, uploadResumeFile, getMentorMentorshipRequests -} from '@/services/mentorship.service'; -import type { ResumeReviewDTO, MentorshipDetailsDTO, MentorshipRequestDTO } from '@/types/api.types'; -import { getMenteeMentorships } from '@/services/mentorship.service'; -import { useAuth } from '@/contexts/AuthContext'; +} from '@modules/mentorship/services/mentorship.service'; +import type { ResumeReviewDTO, MentorshipDetailsDTO, MentorshipRequestDTO } from '@shared/types/api.types'; +import { getMenteeMentorships } from '@modules/mentorship/services/mentorship.service'; +import { useAuth } from '@shared/contexts/AuthContext'; export default function ResumeReviewPage() { const { resumeReviewId } = useParams<{ resumeReviewId: string }>(); diff --git a/apps/jobboard-frontend/src/pages/NonProfitJobApplicationPage.tsx b/apps/jobboard-frontend/src/modules/volunteering/pages/NonProfitJobApplicationPage.tsx similarity index 96% rename from apps/jobboard-frontend/src/pages/NonProfitJobApplicationPage.tsx rename to apps/jobboard-frontend/src/modules/volunteering/pages/NonProfitJobApplicationPage.tsx index aa1ba2d4..c3562172 100644 --- a/apps/jobboard-frontend/src/pages/NonProfitJobApplicationPage.tsx +++ b/apps/jobboard-frontend/src/modules/volunteering/pages/NonProfitJobApplicationPage.tsx @@ -3,16 +3,16 @@ import { useParams, useNavigate, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { ChevronRight, Upload, X, FileText, Heart } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import { Badge } from '@/components/ui/badge'; -import CenteredLoader from '@/components/CenteredLoader'; -import { cn } from '@/lib/utils'; -import type { JobPostResponse } from '@/types/api.types'; -import { getJobById } from '@/services/jobs.service'; -import { createApplication, uploadCv, getApplicationsByJobSeeker } from '@/services/applications.service'; -import { useAuth } from '@/contexts/AuthContext'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Label } from '@shared/components/ui/label'; +import { Badge } from '@shared/components/ui/badge'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import { cn } from '@shared/lib/utils'; +import type { JobPostResponse } from '@shared/types/api.types'; +import { getJobById } from '@modules/jobs/services/jobs.service'; +import { createApplication, uploadCv, getApplicationsByJobSeeker } from '@modules/applications/services/applications.service'; +import { useAuth } from '@shared/contexts/AuthContext'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const ALLOWED_FILE_TYPES = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']; @@ -357,4 +357,4 @@ export default function NonProfitJobApplicationPage() { ); -} \ No newline at end of file +} diff --git a/apps/jobboard-frontend/src/pages/NonProfitJobDetailPage.tsx b/apps/jobboard-frontend/src/modules/volunteering/pages/NonProfitJobDetailPage.tsx similarity index 93% rename from apps/jobboard-frontend/src/pages/NonProfitJobDetailPage.tsx rename to apps/jobboard-frontend/src/modules/volunteering/pages/NonProfitJobDetailPage.tsx index 905490b2..f0540ba1 100644 --- a/apps/jobboard-frontend/src/pages/NonProfitJobDetailPage.tsx +++ b/apps/jobboard-frontend/src/modules/volunteering/pages/NonProfitJobDetailPage.tsx @@ -2,14 +2,14 @@ import { useEffect, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ChevronRight, Heart, Accessibility } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import CenteredLoader from '@/components/CenteredLoader'; -import { WorkplaceCard } from '@/components/workplace/WorkplaceCard'; -import type { JobPostResponse } from '@/types/api.types'; -import { getJobById } from '@/services/jobs.service'; -import { cn } from '@/lib/utils'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import { WorkplaceCard } from '@modules/workplace/components/workplace/WorkplaceCard'; +import type { JobPostResponse } from '@shared/types/api.types'; +import { getJobById } from '@modules/jobs/services/jobs.service'; +import { cn } from '@shared/lib/utils'; export default function NonProfitJobDetailPage() { const { id } = useParams<{ id: string }>(); @@ -170,4 +170,4 @@ export default function NonProfitJobDetailPage() { ); -} \ No newline at end of file +} diff --git a/apps/jobboard-frontend/src/pages/NonProfitJobsPage.tsx b/apps/jobboard-frontend/src/modules/volunteering/pages/NonProfitJobsPage.tsx similarity index 94% rename from apps/jobboard-frontend/src/pages/NonProfitJobsPage.tsx rename to apps/jobboard-frontend/src/modules/volunteering/pages/NonProfitJobsPage.tsx index 5a0a57e7..5c7977f4 100644 --- a/apps/jobboard-frontend/src/pages/NonProfitJobsPage.tsx +++ b/apps/jobboard-frontend/src/modules/volunteering/pages/NonProfitJobsPage.tsx @@ -2,13 +2,13 @@ import { useEffect, useState } from 'react'; import { AlertCircle, Heart, Leaf, Users } from 'lucide-react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { NonProfitJobCard } from '@/components/jobs/NonProfitJobCard'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; -import { type Job } from '@/types/job'; -import { getJobs } from '@/services/jobs.service'; -import type { JobPostResponse } from '@/types/api.types'; -import CenteredLoader from '@/components/CenteredLoader'; +import { NonProfitJobCard } from '@modules/jobs/components/jobs/NonProfitJobCard'; +import { Button } from '@shared/components/ui/button'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { type Job } from '@shared/types/job'; +import { getJobs } from '@modules/jobs/services/jobs.service'; +import type { JobPostResponse } from '@shared/types/api.types'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; import { AxiosError } from 'axios'; /** diff --git a/apps/jobboard-frontend/src/pages/EmployerWorkplacesPage.tsx b/apps/jobboard-frontend/src/modules/workplace/pages/EmployerWorkplacesPage.tsx similarity index 92% rename from apps/jobboard-frontend/src/pages/EmployerWorkplacesPage.tsx rename to apps/jobboard-frontend/src/modules/workplace/pages/EmployerWorkplacesPage.tsx index 42a68e8a..1701f62e 100644 --- a/apps/jobboard-frontend/src/pages/EmployerWorkplacesPage.tsx +++ b/apps/jobboard-frontend/src/modules/workplace/pages/EmployerWorkplacesPage.tsx @@ -5,27 +5,27 @@ import { useEffect, useState } from 'react'; import { format } from 'date-fns'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; +} from '@shared/components/ui/dialog'; import { useTranslation } from 'react-i18next'; import { Plus, Building2, ListChecks, Loader2 } from 'lucide-react'; -import { getMyWorkplaces, getEmployerRequests, getMyEmployerRequests } from '@/services/employer.service'; -import type { EmployerWorkplaceBrief, EmployerRequestResponse } from '@/types/workplace.types'; -import CenteredLoader from '@/components/CenteredLoader'; -import CenteredError from '@/components/CenteredError'; -import { NewWorkplaceModal } from '@/components/workplace/NewWorkplaceModal'; -import { JoinWorkplaceModal } from '@/components/workplace/JoinWorkplaceModal'; -import { CreateWorkplaceModal } from '@/components/workplace/CreateWorkplaceModal'; -import { EmployerWorkplaceCard } from '@/components/workplace/EmployerWorkplaceCard'; -import { getErrorMessage } from '@/utils/error-handler'; +import { getMyWorkplaces, getEmployerRequests, getMyEmployerRequests } from '@modules/employer/services/employer.service'; +import type { EmployerWorkplaceBrief, EmployerRequestResponse } from '@shared/types/workplace.types'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import CenteredError from '@shared/components/common/CenteredError'; +import { NewWorkplaceModal } from '@modules/workplace/components/workplace/NewWorkplaceModal'; +import { JoinWorkplaceModal } from '@modules/workplace/components/workplace/JoinWorkplaceModal'; +import { CreateWorkplaceModal } from '@modules/workplace/components/workplace/CreateWorkplaceModal'; +import { EmployerWorkplaceCard } from '@modules/workplace/components/workplace/EmployerWorkplaceCard'; +import { getErrorMessage } from '@shared/utils/error-handler'; export default function EmployerWorkplacesPage() { const { t } = useTranslation('common'); diff --git a/apps/jobboard-frontend/src/pages/ManageEmployerRequestsPage.tsx b/apps/jobboard-frontend/src/modules/workplace/pages/ManageEmployerRequestsPage.tsx similarity index 92% rename from apps/jobboard-frontend/src/pages/ManageEmployerRequestsPage.tsx rename to apps/jobboard-frontend/src/modules/workplace/pages/ManageEmployerRequestsPage.tsx index 3ef75fc0..210f895b 100644 --- a/apps/jobboard-frontend/src/pages/ManageEmployerRequestsPage.tsx +++ b/apps/jobboard-frontend/src/modules/workplace/pages/ManageEmployerRequestsPage.tsx @@ -6,27 +6,27 @@ import { useEffect, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { format } from 'date-fns'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Separator } from '@/components/ui/separator'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { Separator } from '@shared/components/ui/separator'; import { Check, X, Users, Trash2 } from 'lucide-react'; import { getEmployerRequests, resolveEmployerRequest, getWorkplaceEmployers, removeEmployer, -} from '@/services/employer.service'; -import { getWorkplaceById } from '@/services/workplace.service'; +} from '@modules/employer/services/employer.service'; +import { getWorkplaceById } from '@modules/workplace/services/workplace.service'; import type { EmployerRequestResponse, EmployerListItem, WorkplaceDetailResponse, -} from '@/types/workplace.types'; -import { useAuth } from '@/contexts/AuthContext'; -import { getErrorMessage } from '@/utils/error-handler'; -import CenteredLoader from '@/components/CenteredLoader'; -import CenteredError from '@/components/CenteredError'; +} from '@shared/types/workplace.types'; +import { useAuth } from '@shared/contexts/AuthContext'; +import { getErrorMessage } from '@shared/utils/error-handler'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import CenteredError from '@shared/components/common/CenteredError'; export default function ManageEmployerRequestsPage() { const { workplaceId } = useParams<{ workplaceId: string }>(); diff --git a/apps/jobboard-frontend/src/pages/WorkplaceProfilePage.tsx b/apps/jobboard-frontend/src/modules/workplace/pages/WorkplaceProfilePage.tsx similarity index 94% rename from apps/jobboard-frontend/src/pages/WorkplaceProfilePage.tsx rename to apps/jobboard-frontend/src/modules/workplace/pages/WorkplaceProfilePage.tsx index a8445393..6fe81db8 100644 --- a/apps/jobboard-frontend/src/pages/WorkplaceProfilePage.tsx +++ b/apps/jobboard-frontend/src/modules/workplace/pages/WorkplaceProfilePage.tsx @@ -6,25 +6,25 @@ import { useEffect, useMemo, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; -import { Separator } from '@/components/ui/separator'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { ReviewStats } from '@/components/reviews/ReviewStats'; -import { ReviewList } from '@/components/reviews/ReviewList'; -import { ReviewFormDialog } from '@/components/reviews/ReviewFormDialog'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Badge } from '@shared/components/ui/badge'; +import { Input } from '@shared/components/ui/input'; +import { Separator } from '@shared/components/ui/separator'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; +import { ReviewStats } from '@modules/resumeReview/components/reviews/ReviewStats'; +import { ReviewList } from '@modules/resumeReview/components/reviews/ReviewList'; +import { ReviewFormDialog } from '@modules/resumeReview/components/reviews/ReviewFormDialog'; import { MapPin, Building2, ExternalLink, Users, Settings, CheckCircle2, ArrowDown, ArrowUp } from 'lucide-react'; -import { getWorkplaceById } from '@/services/workplace.service'; -import { getJobsByEmployer } from '@/services/jobs.service'; -import { getEmployerRequests } from '@/services/employer.service'; -import { useReportModal } from '@/hooks/useReportModal'; -import { reportWorkplace } from '@/services/workplace-report.service'; +import { getWorkplaceById } from '@modules/workplace/services/workplace.service'; +import { getJobsByEmployer } from '@modules/jobs/services/jobs.service'; +import { getEmployerRequests } from '@modules/employer/services/employer.service'; +import { useReportModal } from '@shared/hooks/useReportModal'; +import { reportWorkplace } from '@modules/workplace/services/workplace-report.service'; import { Flag } from 'lucide-react'; -import type { ReviewListParams, WorkplaceDetailResponse } from '@/types/workplace.types'; -import type { JobPostResponse } from '@/types/api.types'; -import { useAuth } from '@/contexts/AuthContext'; +import type { ReviewListParams, WorkplaceDetailResponse } from '@shared/types/workplace.types'; +import type { JobPostResponse } from '@shared/types/api.types'; +import { useAuth } from '@shared/contexts/AuthContext'; export default function WorkplaceProfilePage() { const { id } = useParams<{ id: string }>(); diff --git a/apps/jobboard-frontend/src/pages/WorkplaceSettingsPage.tsx b/apps/jobboard-frontend/src/modules/workplace/pages/WorkplaceSettingsPage.tsx similarity index 96% rename from apps/jobboard-frontend/src/pages/WorkplaceSettingsPage.tsx rename to apps/jobboard-frontend/src/modules/workplace/pages/WorkplaceSettingsPage.tsx index 54940a20..53e91416 100644 --- a/apps/jobboard-frontend/src/pages/WorkplaceSettingsPage.tsx +++ b/apps/jobboard-frontend/src/modules/workplace/pages/WorkplaceSettingsPage.tsx @@ -8,13 +8,13 @@ import { useParams, useNavigate, Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Label } from '@/components/ui/label'; -import { MultiSelectDropdown } from '@/components/ui/multi-select-dropdown'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Input } from '@shared/components/ui/input'; +import { Textarea } from '@shared/components/ui/textarea'; +import { Label } from '@shared/components/ui/label'; +import { MultiSelectDropdown } from '@shared/components/ui/multi-select-dropdown'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; import { Dialog, DialogContent, @@ -22,7 +22,7 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; +} from '@shared/components/ui/dialog'; import { Building2, Upload, ArrowLeft, Save, Trash2, AlertTriangle } from 'lucide-react'; import { getWorkplaceById, @@ -30,15 +30,15 @@ import { uploadWorkplaceImage, deleteWorkplaceImage, deleteWorkplace, -} from '@/services/workplace.service'; +} from '@modules/workplace/services/workplace.service'; import { updateWorkplaceSchema, type UpdateWorkplaceFormData, -} from '@/schemas/update-workplace.schema'; -import type { WorkplaceDetailResponse } from '@/types/workplace.types'; -import type { EthicalTag } from '@/types/job'; -import { getErrorMessage } from '@/utils/error-handler'; -import { useAuth } from '@/contexts/AuthContext'; +} from '@modules/workplace/schemas/update-workplace.schema'; +import type { WorkplaceDetailResponse } from '@shared/types/workplace.types'; +import type { EthicalTag } from '@shared/types/job'; +import { getErrorMessage } from '@shared/utils/error-handler'; +import { useAuth } from '@shared/contexts/AuthContext'; export default function WorkplaceSettingsPage() { const { workplaceId } = useParams<{ workplaceId: string }>(); diff --git a/apps/jobboard-frontend/src/pages/WorkplacesPage.tsx b/apps/jobboard-frontend/src/modules/workplace/pages/WorkplacesPage.tsx similarity index 94% rename from apps/jobboard-frontend/src/pages/WorkplacesPage.tsx rename to apps/jobboard-frontend/src/modules/workplace/pages/WorkplacesPage.tsx index 86907c6c..708301d2 100644 --- a/apps/jobboard-frontend/src/pages/WorkplacesPage.tsx +++ b/apps/jobboard-frontend/src/modules/workplace/pages/WorkplacesPage.tsx @@ -4,17 +4,17 @@ */ import { useState, useEffect, useCallback } from 'react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Slider } from '@/components/ui/slider'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; +import { Input } from '@shared/components/ui/input'; +import { Label } from '@shared/components/ui/label'; +import { Slider } from '@shared/components/ui/slider'; import { Search, Building2, Filter, X, ChevronLeft, ChevronRight } from 'lucide-react'; -import { WorkplaceCard } from '@/components/workplace/WorkplaceCard'; -import { getWorkplaces } from '@/services/workplace.service'; -import type { WorkplaceBriefResponse } from '@/types/workplace.types'; -import CenteredLoader from '@/components/CenteredLoader'; -import CenteredError from '@/components/CenteredError'; +import { WorkplaceCard } from '@modules/workplace/components/workplace/WorkplaceCard'; +import { getWorkplaces } from '@modules/workplace/services/workplace.service'; +import type { WorkplaceBriefResponse } from '@shared/types/workplace.types'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import CenteredError from '@shared/components/common/CenteredError'; import { useTranslation } from 'react-i18next'; // Common sectors diff --git a/apps/jobboard-frontend/src/pages/__tests__/auth/LoginPage.test.tsx b/apps/jobboard-frontend/src/pages/__tests__/auth/LoginPage.test.tsx deleted file mode 100644 index 222dd007..00000000 --- a/apps/jobboard-frontend/src/pages/__tests__/auth/LoginPage.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { vi } from 'vitest'; -import LoginPage from '../../LoginPage'; -import { renderWithProviders, setupUserEvent } from '@/test/utils'; -import { server } from '@/test/setup'; -import { API_BASE_URL } from '@/test/handlers'; - -const mockNavigate = vi.fn(); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => mockNavigate, - }; -}); - -describe('LoginPage', () => { - beforeEach(() => { - mockNavigate.mockReset(); - }); - - it('signs in successfully and navigates home', async () => { - renderWithProviders(, { initialEntries: ['/login'] }); - const user = setupUserEvent(); - - await user.type(screen.getByLabelText('auth.login.username'), 'tester'); - await user.type(screen.getByLabelText('auth.login.password'), 'password123!'); - await user.click(screen.getByRole('button', { name: 'auth.login.submit' })); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/'); - }); - }); - - it('redirects to OTP verification when temporary token is returned', async () => { - server.use( - http.post(`${API_BASE_URL}/auth/login`, async () => - HttpResponse.json( - { temporaryToken: 'tmp-token', token: null }, - { status: 200 }, - ), - ), - ); - - renderWithProviders(, { initialEntries: ['/login'] }); - const user = setupUserEvent(); - - await user.type(screen.getByLabelText('auth.login.username'), 'otp-user'); - await user.type(screen.getByLabelText('auth.login.password'), 'password123!'); - await user.click(screen.getByRole('button', { name: 'auth.login.submit' })); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - '/verify-otp', - expect.objectContaining({ - state: expect.objectContaining({ - temporaryToken: 'tmp-token', - username: 'otp-user', - }), - }), - ); - }); - }); - - it('surfaces invalid credential errors from the API', async () => { - server.use( - http.post(`${API_BASE_URL}/auth/login`, async () => - HttpResponse.json( - { message: 'Invalid credentials' }, - { status: 401 }, - ), - ), - ); - - renderWithProviders(, { initialEntries: ['/login'] }); - const user = setupUserEvent(); - - await user.type(screen.getByLabelText('auth.login.username'), 'wrong'); - await user.type(screen.getByLabelText('auth.login.password'), 'nope'); - await user.click(screen.getByRole('button', { name: 'auth.login.submit' })); - - expect(await screen.findByRole('alert')).toHaveTextContent('auth.login.errors.invalidCredentials'); - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('shows success message when provided via navigation state', async () => { - const successMessage = 'Password reset successful! You can now log in with your new password.'; - - renderWithProviders(, { - initialEntries: [ - { - pathname: '/login', - state: { message: successMessage }, - }, - ], - }); - - expect(await screen.findByRole('alert')).toHaveTextContent(successMessage); - }); -}); diff --git a/apps/jobboard-frontend/src/pages/__tests__/auth/RegisterPage.test.tsx b/apps/jobboard-frontend/src/pages/__tests__/auth/RegisterPage.test.tsx deleted file mode 100644 index a077ef45..00000000 --- a/apps/jobboard-frontend/src/pages/__tests__/auth/RegisterPage.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { screen } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import RegisterPage from '../../RegisterPage'; -import { renderWithProviders, setupUserEvent } from '@/test/utils'; -import { server } from '@/test/setup'; -import { API_BASE_URL } from '@/test/handlers'; - -describe('RegisterPage', () => { - async function completeRequiredFields() { - const user = setupUserEvent(); - await user.type(screen.getByLabelText('auth.register.username'), 'newuser'); - await user.type(screen.getByLabelText('auth.register.email'), 'newuser@example.com'); - await user.type(screen.getByLabelText('auth.register.password'), 'StrongPass1!'); - await user.type(screen.getByLabelText('auth.register.confirmPassword'), 'StrongPass1!'); - await user.selectOptions(screen.getByLabelText('auth.register.role'), 'ROLE_JOBSEEKER'); - await user.click(screen.getByLabelText('auth.register.termsLabelPlain')); - return user; - } - - it('shows a success message when registration completes', async () => { - renderWithProviders(, { initialEntries: ['/register'] }); - - const user = await completeRequiredFields(); - await user.click(screen.getByRole('button', { name: 'auth.register.submit' })); - - expect( - await screen.findByText('auth.register.success'), - ).toBeInTheDocument(); - }); - - it('maps backend duplicate email errors to localized copy', async () => { - server.use( - http.post(`${API_BASE_URL}/auth/register`, async () => - HttpResponse.json( - { message: 'Error: Email is already in use' }, - { status: 200 }, - ), - ), - ); - - renderWithProviders(, { initialEntries: ['/register'] }); - - const user = await completeRequiredFields(); - await user.click(screen.getByRole('button', { name: 'auth.register.submit' })); - - expect( - await screen.findByRole('alert'), - ).toHaveTextContent('auth.register.errors.emailInUse'); - }); - - it('shows a service unavailable message on 401 responses', async () => { - server.use( - http.post(`${API_BASE_URL}/auth/register`, async () => - HttpResponse.json( - { message: 'Service down' }, - { status: 401 }, - ), - ), - ); - - renderWithProviders(, { initialEntries: ['/register'] }); - - const user = await completeRequiredFields(); - await user.click(screen.getByRole('button', { name: 'auth.register.submit' })); - - expect( - await screen.findByRole('alert'), - ).toHaveTextContent('auth.register.errors.serviceUnavailable'); - }); -}); diff --git a/apps/jobboard-frontend/src/pages/__tests__/auth/ResetPasswordPage.test.tsx b/apps/jobboard-frontend/src/pages/__tests__/auth/ResetPasswordPage.test.tsx deleted file mode 100644 index 7b14c168..00000000 --- a/apps/jobboard-frontend/src/pages/__tests__/auth/ResetPasswordPage.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import { http, HttpResponse } from 'msw'; -import { vi } from 'vitest'; -import ResetPasswordPage from '../../ResetPasswordPage'; -import { renderWithProviders, setupUserEvent } from '@/test/utils'; -import { server } from '@/test/setup'; -import { API_BASE_URL } from '@/test/handlers'; - -const mockNavigate = vi.fn(); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => mockNavigate, - }; -}); - -describe('ResetPasswordPage', () => { - beforeEach(() => { - mockNavigate.mockReset(); - }); - - it('requires a token in the query string', () => { - renderWithProviders(, { initialEntries: ['/reset-password'] }); - expect( - screen.getByText('auth.reset.invalidDescription'), - ).toBeInTheDocument(); - }); - - it('submits a new password and redirects to login with success message', async () => { - renderWithProviders(, { initialEntries: ['/reset-password?token=abc123'] }); - const user = setupUserEvent(); - - await user.type(screen.getByLabelText('auth.reset.newPassword'), 'StrongPass1!'); - await user.type(screen.getByLabelText('auth.reset.confirmPassword'), 'StrongPass1!'); - await user.click(screen.getByRole('button', { name: 'auth.reset.submit' })); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - '/login', - expect.objectContaining({ - replace: true, - state: expect.objectContaining({ - message: 'auth.reset.success', - }), - }), - ); - }); - }); - - it('shows translated error when backend rejects password reuse', async () => { - server.use( - http.post(`${API_BASE_URL}/auth/password-reset/confirm`, async () => - HttpResponse.json( - { message: 'New password cannot be the same as the old password' }, - { status: 400 }, - ), - ), - ); - - renderWithProviders(, { initialEntries: ['/reset-password?token=abc123'] }); - const user = setupUserEvent(); - - await user.type(screen.getByLabelText('auth.reset.newPassword'), 'SamePassword1!'); - await user.type(screen.getByLabelText('auth.reset.confirmPassword'), 'SamePassword1!'); - await user.click(screen.getByRole('button', { name: 'auth.reset.submit' })); - - expect( - await screen.findByRole('alert'), - ).toHaveTextContent('auth.reset.errors.sameAsOld'); - expect(mockNavigate).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerDashboardPage.test.tsx b/apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerDashboardPage.test.tsx deleted file mode 100644 index a5f1f432..00000000 --- a/apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerDashboardPage.test.tsx +++ /dev/null @@ -1,649 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; -import { renderWithProviders, setupUserEvent } from '@/test/utils'; -import { useAuthStore } from '@/stores/authStore'; -import { createMockJWT, createMockJob, createMockApplication } from '@/test/handlers'; -import { server } from '@/test/setup'; -import { http, HttpResponse } from 'msw'; -import { API_BASE_URL } from '@/test/handlers'; -import EmployerDashboardPage from '@/pages/EmployerDashboardPage'; - -const mockNavigate = vi.fn(); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => mockNavigate, - }; -}); - -describe('EmployerDashboardPage', () => { - const setupAuthState = () => { - // Set up authenticated employer user - useAuthStore.setState({ - user: { - id: 1, - username: 'employer1', - email: 'employer@test.com', - role: 'ROLE_EMPLOYER' - }, - accessToken: createMockJWT('employer1', 'employer@test.com', 1, 'ROLE_EMPLOYER'), - isAuthenticated: true, - }); - }; - - beforeEach(() => { - mockNavigate.mockReset(); - }); - - it('redirects when user is not authenticated', async () => { - // Clear auth state to simulate unauthenticated user - useAuthStore.setState({ - user: null, - accessToken: null, - isAuthenticated: false, - }); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - - // Wait for component to process auth state - await waitFor(() => { - expect(screen.getByText('auth.login.errors.generic')).toBeInTheDocument(); - }); - }); - - it.skip('shows loading state during data fetch', async () => { - // Skip: This test has a race condition where renderWithProviders clears auth state, - // causing the component to render without a user initially. By the time we set the - // auth state after render, the loading state has already been cleared. - // The loading state is properly tested in other scenarios. - }); - - it('displays empty state when user belongs to no workplaces', async () => { - // Reset all handlers and set up empty mocks - server.resetHandlers(); - server.use( - http.get(`${API_BASE_URL}/jobs/employer/1`, async () => { - return HttpResponse.json([]); - }), - http.get(`${API_BASE_URL}/workplace/employers/me`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Check for empty state message - expect(screen.getByText('employerDashboard.noWorkplaces.title')).toBeInTheDocument(); - expect(screen.getByText('employerDashboard.noWorkplaces.description')).toBeInTheDocument(); - }); - - it('renders job list with title, status badges, and application counts', async () => { - const mockJobs = [ - createMockJob({ id: 1, title: 'Senior Software Engineer', employerId: 1 }), - createMockJob({ id: 2, title: 'Frontend Developer', employerId: 1, workplace: { id: 1, companyName: 'WebDev Inc', sector: 'Technology', location: 'San Francisco, CA', overallAvg: 4.5, ethicalTags: [], ethicalAverages: {} } }), - createMockJob({ id: 3, title: 'Backend Engineer', employerId: 1, workplace: { id: 2, companyName: 'DataSoft', sector: 'Technology', location: 'San Francisco, CA', overallAvg: 4.5, ethicalTags: [], ethicalAverages: {} } }), - ]; - - const mockApps = [ - createMockApplication({ id: 1, jobPostId: 1 }), - createMockApplication({ id: 2, jobPostId: 1 }), - createMockApplication({ id: 3, jobPostId: 2 }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async ({ request }) => { - const url = new URL(request.url); - const jobPostId = url.searchParams.get('jobPostId'); - const filtered = mockApps.filter(app => app.jobPostId === Number(jobPostId)); - return HttpResponse.json(filtered); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('Senior Software Engineer')).toBeInTheDocument(); - }); - - expect(screen.getByText('Frontend Developer')).toBeInTheDocument(); - expect(screen.getByText('Backend Engineer')).toBeInTheDocument(); - - // Check application counts (job 1 has 2 apps, job 2 has 1 app, job 3 has 0 apps) - const applicationCells = screen.getAllByRole('cell', { name: /\d+/ }); - expect(applicationCells.some(cell => cell.textContent === '2')).toBe(true); - expect(applicationCells.some(cell => cell.textContent === '1')).toBe(true); - expect(applicationCells.some(cell => cell.textContent === '0')).toBe(true); - - // Check for status badges - expect(screen.getAllByText('employerDashboard.statusLabels.open').length).toBeGreaterThan(0); - }); - - it('navigates to create job page on button click', async () => { - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json([]); - }), - http.get(`${API_BASE_URL}/workplaces/my-workplaces`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Find and click the "Create Workplace" button (since there are no workplaces) - const createButtons = screen.getAllByRole('button'); - // Should have create workplace and join workplace buttons in the empty state - expect(createButtons.length).toBeGreaterThan(0); - }); - - it('navigates to manage job page when clicking job row', async () => { - const mockJobs = [ - createMockJob({ id: 1, title: 'Test Job', employerId: 1 }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.getByText('Test Job')).toBeInTheDocument(); - }); - - // Click the "Manage" button - const manageButton = screen.getByRole('button', { name: 'employerDashboard.actions.manage' }); - await user.click(manageButton); - - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1'); - }); - - it('handles API errors gracefully with error message', async () => { - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json( - { message: 'Internal server error' }, - { status: 500 } - ); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('employerDashboard.loadingError')).toBeInTheDocument(); - }); - - // Should show retry button - expect(screen.getByRole('button', { name: 'jobs.retry' })).toBeInTheDocument(); - }); - - it('defaults application count to 0 if fetch fails', async () => { - const mockJobs = [ - createMockJob({ id: 1, title: 'Test Job', employerId: 1 }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json( - { message: 'Application fetch failed' }, - { status: 500 } - ); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('Test Job')).toBeInTheDocument(); - }); - - // Should default to 0 applications when fetch fails - const applicationCells = screen.getAllByRole('cell'); - const hasZeroApplications = applicationCells.some(cell => cell.textContent === '0'); - expect(hasZeroApplications).toBe(true); - }); - - it('displays correct status badge variants (OPEN, ACTIVE, PAUSED)', async () => { - // Note: The component defaults all jobs to OPEN status since API doesn't provide it - // This test verifies the badge rendering infrastructure is in place - const mockJobs = [ - createMockJob({ id: 1, title: 'Job 1', employerId: 1 }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('Job 1')).toBeInTheDocument(); - }); - - // Should display status badges - const badges = screen.getAllByText('employerDashboard.statusLabels.open'); - expect(badges.length).toBeGreaterThan(0); - }); - - it('handles parallel application count fetches for multiple jobs', async () => { - const mockJobs = [ - createMockJob({ id: 1, title: 'Job 1', employerId: 1 }), - createMockJob({ id: 2, title: 'Job 2', employerId: 1 }), - createMockJob({ id: 3, title: 'Job 3', employerId: 1 }), - ]; - - let applicationCallCount = 0; - - server.use( - http.get(`${API_BASE_URL}/workplace/employers/me`, async () => { - return HttpResponse.json([{ - role: 'ADMIN', - workplace: { - id: 1, - companyName: 'Test Company', - sector: 'Technology', - location: 'Test City', - shortDescription: 'Test description', - overallAvg: 4.0, - ethicalTags: [], - ethicalAverages: {} - } - }]); - }), - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - applicationCallCount++; - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('Job 1')).toBeInTheDocument(); - expect(screen.getByText('Job 2')).toBeInTheDocument(); - expect(screen.getByText('Job 3')).toBeInTheDocument(); - }); - - // Should have made parallel calls for application counts (actual count may vary based on implementation) - expect(applicationCallCount).toBeGreaterThan(0); - }); - - // Additional test scenarios - it('displays very long job titles correctly', async () => { - const longTitle = 'Senior Principal Staff Lead Architect Engineer Manager Director of Software Development and Innovation'; - const mockJobs = [ - createMockJob({ id: 1, title: longTitle, employerId: 1 }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText(longTitle)).toBeInTheDocument(); - }); - }); - - it('handles large number of jobs', async () => { - const mockJobs = Array.from({ length: 50 }, (_, i) => - createMockJob({ id: i + 1, title: `Job ${i + 1}`, employerId: 1 }) - ); - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('Job 1')).toBeInTheDocument(); - }); - - // Check that multiple jobs are rendered - expect(screen.getByText('Job 50')).toBeInTheDocument(); - }); - - it('displays high application counts correctly', async () => { - const mockJobs = [ - createMockJob({ id: 1, title: 'Popular Job', employerId: 1 }), - ]; - - const mockApps = Array.from({ length: 99 }, (_, i) => - createMockApplication({ id: i + 1, jobPostId: 1 }) - ); - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json(mockApps); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('Popular Job')).toBeInTheDocument(); - }); - - // Check for high application count - await waitFor(() => { - expect(screen.getByText('99')).toBeInTheDocument(); - }); - }); - - it('retries loading jobs when retry button is clicked', async () => { - let callCount = 0; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - callCount++; - if (callCount === 1) { - return HttpResponse.json( - { message: 'Server error' }, - { status: 500 } - ); - } - return HttpResponse.json([createMockJob({ id: 1, title: 'Recovered Job', employerId: 1 })]); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.getByText('employerDashboard.loadingError')).toBeInTheDocument(); - }); - - const retryButton = screen.getByRole('button', { name: 'jobs.retry' }); - await user.click(retryButton); - - await waitFor(() => { - expect(screen.getByText('Recovered Job')).toBeInTheDocument(); - }); - }); - - it('displays jobs with special characters in title', async () => { - const mockJobs = [ - createMockJob({ - id: 1, - title: 'C++ & C# Developer @ Tech&Co', - employerId: 1, - workplace: { - id: 1, - companyName: 'Tech & Innovation Inc.', - sector: 'Technology', - location: 'San Francisco', - overallAvg: 4.5, - ethicalTags: [], - ethicalAverages: {}, - }, - }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('C++ & C# Developer @ Tech&Co')).toBeInTheDocument(); - }); - }); - - it('navigates to create job from empty state button', async () => { - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json([]); - }), - http.get(`${API_BASE_URL}/workplace/employers/me`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('employerDashboard.noWorkplaces.title')).toBeInTheDocument(); - }); - - // In the empty state, there should be create and join workplace buttons - const createButton = screen.getByRole('button', { name: 'employerDashboard.noWorkplaces.createWorkplace' }); - expect(createButton).toBeInTheDocument(); - }); - - it('shows correct application count when some jobs have applications and others do not', async () => { - const mockJobs = [ - createMockJob({ id: 1, title: 'Job With Apps', employerId: 1 }), - createMockJob({ id: 2, title: 'Job Without Apps', employerId: 1 }), - ]; - - const mockApps = [ - createMockApplication({ id: 1, jobPostId: 1 }), - createMockApplication({ id: 2, jobPostId: 1 }), - createMockApplication({ id: 3, jobPostId: 1 }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async ({ request }) => { - const url = new URL(request.url); - const jobPostId = url.searchParams.get('jobPostId'); - const filtered = mockApps.filter(app => app.jobPostId === Number(jobPostId)); - return HttpResponse.json(filtered); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('Job With Apps')).toBeInTheDocument(); - }); - - expect(screen.getByText('Job Without Apps')).toBeInTheDocument(); - - // Check application counts - const cells = screen.getAllByRole('cell'); - const hasThreeApps = cells.some(cell => cell.textContent === '3'); - const hasZeroApps = cells.some(cell => cell.textContent === '0'); - - expect(hasThreeApps).toBe(true); - expect(hasZeroApps).toBe(true); - }); - - it('handles jobs with missing or undefined fields gracefully', async () => { - const mockJobs = [ - createMockJob({ - id: 1, - title: 'Minimal Job', - location: '', - employerId: 1 - }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('Minimal Job')).toBeInTheDocument(); - }); - }); - - it('displays multiple manage buttons for multiple jobs', async () => { - const mockJobs = [ - createMockJob({ id: 1, title: 'Job 1', employerId: 1 }), - createMockJob({ id: 2, title: 'Job 2', employerId: 1 }), - createMockJob({ id: 3, title: 'Job 3', employerId: 1 }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - await waitFor(() => { - expect(screen.getByText('Job 1')).toBeInTheDocument(); - }); - - const manageButtons = screen.getAllByRole('button', { name: 'employerDashboard.actions.manage' }); - expect(manageButtons.length).toBe(3); - }); - - it('navigates to correct job when clicking different manage buttons', async () => { - const mockJobs = [ - createMockJob({ id: 5, title: 'Job Alpha', employerId: 1 }), - createMockJob({ id: 10, title: 'Job Beta', employerId: 1 }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/employer/:employerId`, async () => { - return HttpResponse.json(mockJobs); - }), - http.get(`${API_BASE_URL}/applications`, async () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/dashboard'] - }); - setupAuthState(); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.getByText('Job Alpha')).toBeInTheDocument(); - }); - - const manageButtons = screen.getAllByRole('button', { name: 'employerDashboard.actions.manage' }); - - // Click first manage button - await user.click(manageButtons[0]); - - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/5'); - }); -}); diff --git a/apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerEditJobPostPage.test.tsx b/apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerEditJobPostPage.test.tsx deleted file mode 100644 index ccaf4009..00000000 --- a/apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerEditJobPostPage.test.tsx +++ /dev/null @@ -1,846 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { screen, waitFor, fireEvent } from '@testing-library/react'; -import { renderWithProviders, setupUserEvent } from '@/test/utils'; -import { server } from '@/test/setup'; -import { http, HttpResponse } from 'msw'; -import { API_BASE_URL, createMockJob } from '@/test/handlers'; -import EmployerEditJobPostPage from '@/pages/EmployerEditJobPostPage'; -import type { UpdateJobPostRequest } from '@/types/api.types'; -import type { EmployerWorkplaceBrief } from '@/types/workplace.types'; - -// Mock WorkplaceSelector to avoid dropdown issues in tests -vi.mock('@/components/workplace/WorkplaceSelector', () => ({ - default: ({ value, onChange }: { value: number | undefined; onChange: (id: number, workplace: Partial) => void }) => ( -
- -
- ), -})); - -const mockNavigate = vi.fn(); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => mockNavigate, - useParams: () => ({ jobId: '1' }), - }; -}); - -describe('EmployerEditJobPostPage', () => { - beforeEach(() => { - mockNavigate.mockReset(); - }); - - it.skip('shows error when jobId param is missing', async () => { - // Skip: Cannot properly mock useParams to return undefined in this test setup - // The mock is defined at module level and cannot be easily overridden per-test - }); - - it('shows loading state while fetching job data', async () => { - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - await new Promise(resolve => setTimeout(resolve, 100)); - return HttpResponse.json(createMockJob()); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - // Should show loader - expect(screen.getByRole('status')).toBeInTheDocument(); - }); - - it('handles fetch error gracefully', async () => { - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json( - { message: 'Job not found' }, - { status: 404 } - ); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.getByText('editJob.loadError')).toBeInTheDocument(); - }); - }); - - it('pre-populates form with existing job data', async () => { - const mockJob = createMockJob({ - id: 1, - title: 'Existing Job Title', - description: 'Existing job description', - location: 'Existing Location', - remote: true, - minSalary: 100000, - maxSalary: 150000, - contact: 'existing@company.com', - inclusiveOpportunity: true, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Check form is pre-populated - expect((screen.getByLabelText('createJob.jobTitle') as HTMLInputElement).value).toBe('Existing Job Title'); - expect((screen.getByLabelText('createJob.jobDescription') as HTMLTextAreaElement).value).toBe('Existing job description'); - expect((screen.getByLabelText('createJob.location') as HTMLInputElement).value).toBe('Existing Location'); - expect((screen.getByLabelText('createJob.minimum') as HTMLInputElement).value).toBe('100000'); - expect((screen.getByLabelText('createJob.maximum') as HTMLInputElement).value).toBe('150000'); - expect((screen.getByLabelText('createJob.contactEmail') as HTMLInputElement).value).toBe('existing@company.com'); - expect(screen.getByLabelText('createJob.remoteWork')).toBeChecked(); - expect(screen.getByLabelText('createJob.inclusiveOpportunity')).toBeChecked(); - }); - - it('parses JSON contact format correctly', async () => { - const mockJob = createMockJob({ - contact: '{"email":"json@company.com"}', - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Should parse JSON and extract email - expect((screen.getByLabelText('createJob.contactEmail') as HTMLInputElement).value).toBe('json@company.com'); - }); - - it('parses plain string contact format correctly', async () => { - const mockJob = createMockJob({ - contact: 'plain@company.com', - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Should use plain string as email - expect((screen.getByLabelText('createJob.contactEmail') as HTMLInputElement).value).toBe('plain@company.com'); - }); - - it('displays workplace selector', async () => { - const mockJob = createMockJob({ - id: 1, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Workplace selector should be displayed - expect(screen.getByText('createJob.workplaceDescription')).toBeInTheDocument(); - }); - - it('updates job successfully with changed values', async () => { - const mockJob = createMockJob({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }), - http.put(`${API_BASE_URL}/jobs/:id`, async ({ request }) => { - const body = await request.json() as UpdateJobPostRequest; - return HttpResponse.json({ - ...mockJob, - ...body, - }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Change title - const titleInput = screen.getByLabelText('createJob.jobTitle'); - await user.clear(titleInput); - await user.type(titleInput, 'Updated Job Title'); - - // Submit form - const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - await user.click(submitButton); - - // Should navigate to job detail page on success - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1'); - }); - }); - - it('shows error toast on update failure', async () => { - const mockJob = createMockJob({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }), - http.put(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json( - { message: 'Update failed' }, - { status: 500 } - ); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Submit form - const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - await user.click(submitButton); - - // Should show error message - await waitFor(() => { - expect(screen.getByText('editJob.submitError')).toBeInTheDocument(); - }); - - // Should NOT navigate - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('navigates to detail page on cancel button click', async () => { - const mockJob = createMockJob({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Click cancel button - const cancelButton = screen.getByRole('button', { name: 'editJob.cancel' }); - await user.click(cancelButton); - - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1'); - }); - - it('handles optional salary fields (can be undefined)', async () => { - const mockJob = createMockJob({ - id: 1, - minSalary: undefined, - maxSalary: undefined, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }), - http.put(`${API_BASE_URL}/jobs/:id`, async ({ request }) => { - const body = await request.json() as UpdateJobPostRequest; - // Salary should be undefined if not provided - expect(body.minSalary).toBeUndefined(); - expect(body.maxSalary).toBeUndefined(); - return HttpResponse.json({ - ...mockJob, - ...body, - }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Salary fields should be empty - expect((screen.getByLabelText('createJob.minimum') as HTMLInputElement).value).toBe(''); - expect((screen.getByLabelText('createJob.maximum') as HTMLInputElement).value).toBe(''); - - // Submit without filling salary - const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - await user.click(submitButton); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalled(); - }); - }); - - it('disables buttons during submission', async () => { - const mockJob = createMockJob({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }), - http.put(`${API_BASE_URL}/jobs/:id`, async () => { - await new Promise(resolve => setTimeout(resolve, 100)); - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - const cancelButton = screen.getByRole('button', { name: 'editJob.cancel' }); - - // Submit form - await user.click(submitButton); - - // Both buttons should be disabled during submission - await waitFor(() => { - expect(submitButton).toBeDisabled(); - expect(cancelButton).toBeDisabled(); - }); - }); - - it('shows "Saving..." text during submission', async () => { - const mockJob = createMockJob({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }), - http.put(`${API_BASE_URL}/jobs/:id`, async () => { - await new Promise(resolve => setTimeout(resolve, 100)); - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Initially shows "Save Changes" - expect(screen.getByRole('button', { name: 'editJob.save' })).toBeInTheDocument(); - - const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - await user.click(submitButton); - - // Should show "Saving..." during submission - await waitFor(() => { - expect(screen.getByRole('button', { name: 'editJob.saving' })).toBeInTheDocument(); - }); - }); - - // Edge case tests - it('handles editing job with very long title and description', async () => { - const longTitle = 'A'.repeat(200); - const longDescription = 'B'.repeat(5000); - - const mockJob = createMockJob({ - id: 1, - title: longTitle, - description: longDescription, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - expect((screen.getByLabelText('createJob.jobTitle') as HTMLInputElement).value).toBe(longTitle); - expect((screen.getByLabelText('createJob.jobDescription') as HTMLTextAreaElement).value).toBe(longDescription); - }); - - it('handles special characters in job fields', async () => { - const mockJob = createMockJob({ - id: 1, - title: 'C++ & C# Developer @ Tech&Co', - location: 'São Paulo, Brasil', - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - expect((screen.getByLabelText('createJob.jobTitle') as HTMLInputElement).value).toBe('C++ & C# Developer @ Tech&Co'); - expect((screen.getByLabelText('createJob.location') as HTMLInputElement).value).toBe('São Paulo, Brasil'); - }); - - // it('updates only changed fields', async () => { - // const mockJob = createMockJob({ - // id: 1, - // title: 'Original Title', - // description: 'Original description', - // }); - - // let updatePayload: UpdateJobPostRequest | null = null; - - // server.use( - // http.get(`${API_BASE_URL}/jobs/:id`, async () => { - // return HttpResponse.json(mockJob); - // }), - // http.put(`${API_BASE_URL}/jobs/:id`, async ({ request }) => { - // updatePayload = await request.json() as UpdateJobPostRequest; - // return HttpResponse.json({ - // ...mockJob, - // ...updatePayload, - // }); - // }) - // ); - - // renderWithProviders(, { - // initialEntries: ['/employer/jobs/1/edit'] - // }); - - // const user = setupUserEvent(); - - // await waitFor(() => { - // expect(screen.queryByRole('status')).not.toBeInTheDocument(); - // }); - - // // Check initial value - // const titleInput = screen.getByLabelText('createJob.jobTitle') as HTMLInputElement; - // expect(titleInput.value).toBe('Original Title'); - - // // Focus the input first - // await user.click(titleInput); - - // // Select all text and type the new title - // await user.keyboard('{Control>}a{/Control}Updated Title Only'); - - // // Wait for the change to propagate - // await waitFor(() => { - // expect(titleInput.value).toBe('Updated Title Only'); - // }, { timeout: 3000 }); - - // const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - // await user.click(submitButton); - - // await waitFor(() => { - // expect(mockNavigate).toHaveBeenCalled(); - // }); - - // // Update should include the changed title - // expect(updatePayload!.title).toBe('Updated Title Only'); - // }); - - it('handles editing salary range', async () => { - const mockJob = createMockJob({ - id: 1, - minSalary: 80000, - maxSalary: 120000, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }), - http.put(`${API_BASE_URL}/jobs/:id`, async ({ request }) => { - const body = await request.json() as UpdateJobPostRequest; - return HttpResponse.json({ - ...mockJob, - ...body, - }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Change salary - const minInput = screen.getByLabelText('createJob.minimum'); - const maxInput = screen.getByLabelText('createJob.maximum'); - - await user.clear(minInput); - await user.type(minInput, '100000'); - - await user.clear(maxInput); - await user.type(maxInput, '150000'); - - const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - await user.click(submitButton); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1'); - }); - }); - - it('toggles remote work and inclusive opportunity checkboxes', async () => { - const mockJob = createMockJob({ - id: 1, - remote: false, - inclusiveOpportunity: false, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }), - http.put(`${API_BASE_URL}/jobs/:id`, async ({ request }) => { - const body = await request.json() as UpdateJobPostRequest; - return HttpResponse.json({ - ...mockJob, - ...body, - }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - const remoteCheckbox = screen.getByLabelText('createJob.remoteWork'); - const inclusiveCheckbox = screen.getByLabelText('createJob.inclusiveOpportunity'); - - // Initially unchecked - expect(remoteCheckbox).not.toBeChecked(); - expect(inclusiveCheckbox).not.toBeChecked(); - - // Toggle both - fireEvent.click(remoteCheckbox); - fireEvent.click(inclusiveCheckbox); - - await waitFor(() => { - expect(remoteCheckbox).toBeChecked(); - expect(inclusiveCheckbox).toBeChecked(); - }); - - const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - await user.click(submitButton); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalled(); - }); - }); - - it('handles network timeout gracefully', async () => { - const mockJob = createMockJob({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }), - http.put(`${API_BASE_URL}/jobs/:id`, async () => { - await new Promise(resolve => setTimeout(resolve, 5000)); // Long delay - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - await user.click(submitButton); - - // Should show saving state - await waitFor(() => { - expect(screen.getByRole('button', { name: 'editJob.saving' })).toBeInTheDocument(); - }); - }); - - it('handles multiline description correctly', async () => { - const multilineDescription = 'Line 1\nLine 2\nLine 3\n\nLine 5'; - const mockJob = createMockJob({ - id: 1, - description: multilineDescription, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - expect((screen.getByLabelText('createJob.jobDescription') as HTMLTextAreaElement).value).toBe(multilineDescription); - }); - - it('renders form fields correctly', async () => { - const mockJob = createMockJob({ - id: 1, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - // Check that all form fields are rendered - expect(screen.getByLabelText('createJob.jobTitle')).toBeInTheDocument(); - expect(screen.getByLabelText('createJob.jobDescription')).toBeInTheDocument(); - expect(screen.getByLabelText('createJob.location')).toBeInTheDocument(); - expect(screen.getByLabelText('createJob.contactEmail')).toBeInTheDocument(); - }); - - // it('preserves form state after failed update', async () => { - // const mockJob = createMockJob({ id: 1 }); - // let updatePayload: UpdateJobPostRequest | null = null; - - // server.use( - // http.get(`${API_BASE_URL}/jobs/:id`, async () => { - // return HttpResponse.json(mockJob); - // }), - // http.put(`${API_BASE_URL}/jobs/:id`, async ({ request }) => { - // updatePayload = await request.json() as UpdateJobPostRequest; - // return HttpResponse.json( - // { message: 'Server error' }, - // { status: 500 } - // ); - // }) - // ); - - // renderWithProviders(, { - // initialEntries: ['/employer/jobs/1/edit'] - // }); - - // const user = setupUserEvent(); - - // await waitFor(() => { - // expect(screen.queryByRole('status')).not.toBeInTheDocument(); - // }); - - // const newTitle = 'Updated Title'; - // const titleInput = screen.getByLabelText('createJob.jobTitle') as HTMLInputElement; - - // // Focus the input first - // await user.click(titleInput); - - // // Select all text and type the new title - // await user.keyboard('{Control>}a{/Control}' + newTitle); - - // // Wait for the change to propagate - // await waitFor(() => { - // expect(titleInput.value).toBe(newTitle); - // }, { timeout: 3000 }); - - // const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - // await user.click(submitButton); - - // await waitFor(() => { - // expect(screen.getByText('editJob.submitError')).toBeInTheDocument(); - // }); - - // // Form should have attempted to update with the new title - // expect(updatePayload!.title).toBe(newTitle); - - // // Try submitting again to verify form state is preserved - // await user.click(submitButton); - - // await waitFor(() => { - // // Should get another error, but with the same payload - // expect(updatePayload!.title).toBe(newTitle); - // }); - // }); - - it('handles very large salary values', async () => { - const mockJob = createMockJob({ - id: 1, - minSalary: 1000000, - maxSalary: 10000000, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - expect((screen.getByLabelText('createJob.minimum') as HTMLInputElement).value).toBe('1000000'); - expect((screen.getByLabelText('createJob.maximum') as HTMLInputElement).value).toBe('10000000'); - }); - - it('handles editing contact email to different format', async () => { - const mockJob = createMockJob({ - id: 1, - contact: 'old@company.com', - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/:id`, async () => { - return HttpResponse.json(mockJob); - }), - http.put(`${API_BASE_URL}/jobs/:id`, async ({ request }) => { - const body = await request.json() as UpdateJobPostRequest; - return HttpResponse.json({ - ...mockJob, - ...body, - }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/edit'] - }); - - const user = setupUserEvent(); - - await waitFor(() => { - expect(screen.queryByRole('status')).not.toBeInTheDocument(); - }); - - const contactInput = screen.getByLabelText('createJob.contactEmail'); - fireEvent.change(contactInput, { target: { value: 'new@company.co.uk' } }); - - await waitFor(() => { - expect((contactInput as HTMLInputElement).value).toBe('new@company.co.uk'); - }); - - const submitButton = screen.getByRole('button', { name: 'editJob.save' }); - await user.click(submitButton); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalled(); - }); - }); -}); diff --git a/apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerJobPostDetailsPage.test.tsx b/apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerJobPostDetailsPage.test.tsx deleted file mode 100644 index 02ce57c4..00000000 --- a/apps/jobboard-frontend/src/pages/__tests__/jobs/EmployerJobPostDetailsPage.test.tsx +++ /dev/null @@ -1,744 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; -import { renderWithProviders, setupUserEvent } from '@/test/utils'; -import { useAuthStore } from '@/stores/authStore'; -import { createMockJWT, createMockJob, createMockApplication } from '@/test/handlers'; -import { server } from '@/test/setup'; -import { http, HttpResponse } from 'msw'; -import { API_BASE_URL } from '@/test/handlers'; -import EmployerJobPostDetailsPage from '@/pages/EmployerJobPostDetailsPage'; - -const mockNavigate = vi.fn(); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => mockNavigate, - useParams: () => ({ jobId: '1' }), - }; -}); - -describe('EmployerJobPostDetailsPage', () => { - const setupAuthState = () => { - // Set up authenticated employer user - useAuthStore.setState({ - user: { - id: 1, - username: 'employer1', - email: 'employer@test.com', - role: 'ROLE_EMPLOYER' - }, - accessToken: createMockJWT('employer1', 'employer@test.com', 1, 'ROLE_EMPLOYER'), - isAuthenticated: true, - }); - }; - - beforeEach(() => { - mockNavigate.mockReset(); - setupAuthState(); - }); - - it('shows loading state while fetching job and applications', () => { - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - expect(screen.getByRole('status')).toBeInTheDocument(); - }); - - it('displays job details correctly after successful fetch', async () => { - const mockJob = createMockJob({ - id: 1, - title: 'Senior Software Engineer', - description: 'We are looking for an experienced software engineer.', - location: 'San Francisco, CA', - minSalary: 120000, - maxSalary: 180000, - remote: false, - inclusiveOpportunity: true, - contact: 'hiring@techcorp.com', - workplace: { - ...createMockJob().workplace, - companyName: 'Tech Corp', - }, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Senior Software Engineer' })).toBeInTheDocument(); - }); - - expect(screen.getByText('We are looking for an experienced software engineer.')).toBeInTheDocument(); - expect(screen.getByText('$120,000 - $180,000 per year')).toBeInTheDocument(); - expect(screen.getByText('San Francisco, CA')).toBeInTheDocument(); - }); - - it('displays ethical tags correctly', async () => { - const mockJob = createMockJob({ - id: 1, - workplace: { - ...createMockJob().workplace, - ethicalTags: ['Salary Transparency', 'Remote-Friendly', 'Mental Health Support'], - }, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('ethicalTags.tags.salaryTransparency')).toBeInTheDocument(); - }); - - expect(screen.getByText('ethicalTags.tags.remoteFriendly')).toBeInTheDocument(); - expect(screen.getByText('ethicalTags.tags.mentalHealthSupport')).toBeInTheDocument(); - }); - - it('does not display ethical tags section when tags are empty', async () => { - const mockJob = createMockJob({ - id: 1, - workplace: { - ...createMockJob().workplace, - ethicalTags: [], - }, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByRole('heading', { name: createMockJob().title })).toBeInTheDocument(); - }); - - expect(screen.queryByText('employerJobPostDetails.ethicalPolicies')).not.toBeInTheDocument(); - }); - - it('displays inclusive opportunity section when enabled', async () => { - const mockJob = createMockJob({ - id: 1, - inclusiveOpportunity: true, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.inclusiveOpportunity')).toBeInTheDocument(); - }); - }); - - it('does not display inclusive opportunity section when disabled', async () => { - const mockJob = createMockJob({ - id: 1, - inclusiveOpportunity: false, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByRole('heading', { name: createMockJob().title })).toBeInTheDocument(); - }); - - expect(screen.queryByText('employerJobPostDetails.inclusiveOpportunity')).not.toBeInTheDocument(); - }); - - it('displays remote location correctly', async () => { - const mockJob = createMockJob({ - id: 1, - remote: true, - location: 'New York, NY', - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.remote (New York, NY)')).toBeInTheDocument(); - }); - }); - - it('parses JSON contact information correctly', async () => { - const mockJob = createMockJob({ - id: 1, - contact: JSON.stringify({ name: 'HR Department', email: 'hr@techcorp.com' }), - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText(/HR Department: hr@techcorp.com/i)).toBeInTheDocument(); - }); - }); - - it('parses plain string contact information correctly', async () => { - const mockJob = createMockJob({ - id: 1, - contact: 'jobs@company.com', - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobs@company.com')).toBeInTheDocument(); - }); - }); - - it('displays applications list when applications exist', async () => { - const mockJob = createMockJob({ id: 1 }); - const mockApplications = [ - createMockApplication({ - id: 1, - applicantName: 'John Doe', - jobPostId: 1, - appliedDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), // 2 days ago - }), - createMockApplication({ - id: 2, - applicantName: 'Jane Smith', - jobPostId: 1, - appliedDate: new Date().toISOString(), // today - }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json(mockApplications); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('John Doe')).toBeInTheDocument(); - }); - - expect(screen.getByText('Jane Smith')).toBeInTheDocument(); - expect(screen.getAllByText('employerJobPostDetails.viewApplication')).toHaveLength(2); - }); - - it('displays empty state when no applications exist', async () => { - const mockJob = createMockJob({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.noApplications')).toBeInTheDocument(); - }); - }); - - it('formats application dates correctly - today', async () => { - const mockJob = createMockJob({ id: 1 }); - const mockApplications = [ - createMockApplication({ - id: 1, - jobPostId: 1, - appliedDate: new Date().toISOString(), - }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json(mockApplications); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.applied.today')).toBeInTheDocument(); - }); - }); - - it('formats application dates correctly - yesterday', async () => { - const mockJob = createMockJob({ id: 1 }); - const mockApplications = [ - createMockApplication({ - id: 1, - jobPostId: 1, - appliedDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), - }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json(mockApplications); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.applied.yesterday')).toBeInTheDocument(); - }); - }); - - it('formats application dates correctly - days ago', async () => { - const mockJob = createMockJob({ id: 1 }); - const mockApplications = [ - createMockApplication({ - id: 1, - jobPostId: 1, - appliedDate: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), - }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json(mockApplications); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.applied.daysAgo')).toBeInTheDocument(); - }); - }); - - it('navigates to edit page when Edit button is clicked', async () => { - const user = setupUserEvent(); - const mockJob = createMockJob({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.editJobPost')).toBeInTheDocument(); - }); - - const editButton = screen.getByText('employerJobPostDetails.editJobPost'); - await user.click(editButton); - - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1/edit'); - }); - - it('navigates to application review page when View Application is clicked', async () => { - const user = setupUserEvent(); - const mockJob = createMockJob({ id: 1 }); - const mockApplications = [ - createMockApplication({ - id: 123, - applicantName: 'John Doe', - jobPostId: 1, - }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json(mockApplications); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('John Doe')).toBeInTheDocument(); - }); - - const viewButton = screen.getByText('employerJobPostDetails.viewApplication'); - await user.click(viewButton); - - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1/applications/123'); - }); - - it('displays error message when job fetch fails', async () => { - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json({ message: 'Job not found' }, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.error.title')).toBeInTheDocument(); - }); - - expect(screen.getByText('employerJobPostDetails.backToDashboard')).toBeInTheDocument(); - }); - - it('handles applications fetch failure gracefully', async () => { - const mockJob = createMockJob({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json({ message: 'Failed to fetch applications' }, { status: 500 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.error.title')).toBeInTheDocument(); - }); - }); - - it('navigates back to dashboard when Back to Dashboard button is clicked', async () => { - const user = setupUserEvent(); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json({ message: 'Job not found' }, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.backToDashboard')).toBeInTheDocument(); - }); - - const backButton = screen.getByText('employerJobPostDetails.backToDashboard'); - await user.click(backButton); - - // Link component navigation is handled by the router, not mockNavigate - expect(backButton).toHaveAttribute('href', '/employer/dashboard'); - }); - - it('displays breadcrumb navigation correctly', async () => { - const mockJob = createMockJob({ - id: 1, - title: 'Frontend Developer', - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByRole('heading', { name: 'Frontend Developer' })).toBeInTheDocument(); - }); - - const breadcrumb = screen.getByLabelText('Breadcrumb'); - expect(breadcrumb).toBeInTheDocument(); - }); - - it('generates correct avatar initials for applicant names', async () => { - const mockJob = createMockJob({ id: 1 }); - const mockApplications = [ - createMockApplication({ - id: 1, - applicantName: 'John Doe', - jobPostId: 1, - }), - createMockApplication({ - id: 2, - applicantName: 'Sarah Jane Smith', - jobPostId: 1, - }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json(mockApplications); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('JD')).toBeInTheDocument(); - }); - - expect(screen.getByText('SJS')).toBeInTheDocument(); - }); - - it('handles long job descriptions correctly', async () => { - const longDescription = 'A'.repeat(5000); - const mockJob = createMockJob({ - id: 1, - description: longDescription, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText(longDescription)).toBeInTheDocument(); - }); - }); - - it('handles very long job titles correctly', async () => { - const longTitle = 'Senior Principal Staff Lead Architect Engineer Manager Director'; - const mockJob = createMockJob({ - id: 1, - title: longTitle, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByRole('heading', { name: longTitle })).toBeInTheDocument(); - }); - }); - - it('handles job with only minimum salary', async () => { - const mockJob = createMockJob({ - id: 1, - minSalary: 80000, - maxSalary: 80000, - }); - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json([]); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('$80,000 - $80,000 per year')).toBeInTheDocument(); - }); - }); - - it('handles multiple applications with different statuses', async () => { - const mockJob = createMockJob({ id: 1 }); - const mockApplications = [ - createMockApplication({ - id: 1, - applicantName: 'Pending Applicant', - jobPostId: 1, - status: 'PENDING', - }), - createMockApplication({ - id: 2, - applicantName: 'Approved Applicant', - jobPostId: 1, - status: 'APPROVED', - }), - createMockApplication({ - id: 3, - applicantName: 'Rejected Applicant', - jobPostId: 1, - status: 'REJECTED', - }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json(mockApplications); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('Pending Applicant')).toBeInTheDocument(); - }); - - expect(screen.getByText('Approved Applicant')).toBeInTheDocument(); - expect(screen.getByText('Rejected Applicant')).toBeInTheDocument(); - }); - - it('displays correct application count in section header', async () => { - const mockJob = createMockJob({ id: 1 }); - const mockApplications = [ - createMockApplication({ id: 1, jobPostId: 1 }), - createMockApplication({ id: 2, jobPostId: 1 }), - createMockApplication({ id: 3, jobPostId: 1 }), - ]; - - server.use( - http.get(`${API_BASE_URL}/jobs/1`, () => { - return HttpResponse.json(mockJob); - }), - http.get(`${API_BASE_URL}/applications`, () => { - return HttpResponse.json(mockApplications); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1'] - }); - - await waitFor(() => { - expect(screen.getByText('employerJobPostDetails.applicationsReceived')).toBeInTheDocument(); - }); - }); -}); diff --git a/apps/jobboard-frontend/src/pages/__tests__/jobs/JobApplicationReviewPage.test.tsx b/apps/jobboard-frontend/src/pages/__tests__/jobs/JobApplicationReviewPage.test.tsx deleted file mode 100644 index a7fc9b93..00000000 --- a/apps/jobboard-frontend/src/pages/__tests__/jobs/JobApplicationReviewPage.test.tsx +++ /dev/null @@ -1,880 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { screen, waitFor, fireEvent } from '@testing-library/react'; -import { renderWithProviders, setupUserEvent } from '@/test/utils'; -import { useAuthStore } from '@/stores/authStore'; -import { createMockJWT, createMockApplication } from '@/test/handlers'; -import { server } from '@/test/setup'; -import { http, HttpResponse } from 'msw'; -import { API_BASE_URL } from '@/test/handlers'; -import JobApplicationReviewPage from '@/pages/JobApplicationReviewPage'; -import type { UpdateJobApplicationRequest } from '@/types/api.types'; - -const mockNavigate = vi.fn(); - -vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom'); - return { - ...actual, - useNavigate: () => mockNavigate, - useParams: () => ({ jobId: '1', applicationId: '1' }), - }; -}); - -describe('JobApplicationReviewPage', () => { - const setupAuthState = () => { - // Set up authenticated employer user - useAuthStore.setState({ - user: { - id: 1, - username: 'employer1', - email: 'employer@test.com', - role: 'ROLE_EMPLOYER' - }, - accessToken: createMockJWT('employer1', 'employer@test.com', 1, 'ROLE_EMPLOYER'), - isAuthenticated: true, - }); - }; - - beforeEach(() => { - mockNavigate.mockReset(); - setupAuthState(); - }); - - it('shows loading state while fetching application', () => { - server.use( - http.get(`${API_BASE_URL}/applications/1`, async () => { - // Delay response to keep loading state visible - await new Promise(resolve => setTimeout(resolve, 100)); - return HttpResponse.json(createMockApplication({ id: 1 })); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - expect(screen.getByRole('status')).toBeInTheDocument(); - }); - - it('displays application details correctly after successful fetch', async () => { - const mockApplication = createMockApplication({ - id: 1, - applicantName: 'John Doe', - title: 'Software Engineer', - company: 'Tech Corp', - status: 'PENDING', - coverLetter: 'I am very excited to apply for this position.', - specialNeeds: 'Requires wheelchair accessibility', - appliedDate: new Date().toISOString(), - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json({ url: 'https://example.com/cv.pdf' }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('John Doe')).toBeInTheDocument(); - }); - - expect(screen.getByText('I am very excited to apply for this position.')).toBeInTheDocument(); - expect(screen.getByText('Requires wheelchair accessibility')).toBeInTheDocument(); - }); - - it('displays cover letter when present', async () => { - const mockApplication = createMockApplication({ - id: 1, - coverLetter: 'This is my cover letter. I have 5 years of experience.', - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('This is my cover letter. I have 5 years of experience.')).toBeInTheDocument(); - }); - - expect(screen.getAllByText('jobApplicationReview.coverLetter')[0]).toBeInTheDocument(); - }); - - it('does not display cover letter section when not present', async () => { - const mockApplication = createMockApplication({ - id: 1, - coverLetter: '', - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText(mockApplication.applicantName)).toBeInTheDocument(); - }); - - expect(screen.queryByText('jobApplicationReview.coverLetter')).not.toBeInTheDocument(); - }); - - it('displays special needs when present', async () => { - const mockApplication = createMockApplication({ - id: 1, - specialNeeds: 'Needs flexible work hours for medical appointments', - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('Needs flexible work hours for medical appointments')).toBeInTheDocument(); - }); - - expect(screen.getByText('jobApplicationReview.specialNeeds')).toBeInTheDocument(); - }); - - it('does not display special needs section when not present', async () => { - const mockApplication = createMockApplication({ - id: 1, - specialNeeds: '', - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText(mockApplication.applicantName)).toBeInTheDocument(); - }); - - expect(screen.queryByText('jobApplicationReview.specialNeeds')).not.toBeInTheDocument(); - }); - - it('displays CV download button when CV is available', async () => { - const mockApplication = createMockApplication({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json('https://example.com/cv.pdf'); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByRole('link', { name: 'jobApplicationReview.download' })).toBeInTheDocument(); - }); - - const downloadButton = screen.getByRole('link', { name: 'jobApplicationReview.download' }); - expect(downloadButton).toHaveAttribute('href', 'https://example.com/cv.pdf'); - expect(downloadButton).toHaveAttribute('target', '_blank'); - }); - - it('displays no CV message when CV is not available', async () => { - const mockApplication = createMockApplication({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.noCvAttached')).toBeInTheDocument(); - }); - }); - - it('shows loading state for CV while fetching', async () => { - const mockApplication = createMockApplication({ id: 1 }); - - let resolveCV: (value: null) => void; - const cvPromise = new Promise((resolve) => { - resolveCV = resolve; - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, async () => { - await cvPromise; - return HttpResponse.json('https://example.com/cv.pdf'); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.loadingCv')).toBeInTheDocument(); - }); - - resolveCV!(null); - }); - - it('allows typing feedback in textarea', async () => { - const mockApplication = createMockApplication({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText(mockApplication.applicantName)).toBeInTheDocument(); - }); - - const feedbackTextarea = screen.getByRole('textbox', { name: /jobApplicationReview\.feedback/i }); - - fireEvent.change(feedbackTextarea, { target: { value: 'Great candidate with strong experience' } }); - - expect(feedbackTextarea).toHaveValue('Great candidate with strong experience'); - }); - - it('approves application successfully', async () => { - const user = setupUserEvent(); - const mockApplication = createMockApplication({ id: 1, status: 'PENDING' }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }), - http.put(`${API_BASE_URL}/applications/1/approve`, async () => { - return HttpResponse.json({ ...mockApplication, status: 'APPROVED' }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.approve')).toBeInTheDocument(); - }); - - const approveButton = screen.getByText('jobApplicationReview.approve'); - await user.click(approveButton); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1'); - }); - }); - - it('approves application with feedback', async () => { - const user = setupUserEvent(); - const mockApplication = createMockApplication({ id: 1, status: 'PENDING' }); - let capturedFeedback: string | undefined = ''; - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }), - http.put(`${API_BASE_URL}/applications/1/approve`, async ({ request }) => { - const body = await request.json() as UpdateJobApplicationRequest; - capturedFeedback = body.feedback; - return HttpResponse.json({ ...mockApplication, status: 'APPROVED', feedback: capturedFeedback }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.approve')).toBeInTheDocument(); - }); - - const feedbackTextarea = screen.getByPlaceholderText('jobApplicationReview.feedbackPlaceholder'); - await user.type(feedbackTextarea, 'Excellent qualifications'); - - const approveButton = screen.getByText('jobApplicationReview.approve'); - await user.click(approveButton); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1'); - }); - }); - - it('rejects application successfully', async () => { - const user = setupUserEvent(); - const mockApplication = createMockApplication({ id: 1, status: 'PENDING' }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }), - http.put(`${API_BASE_URL}/applications/1/reject`, () => { - return HttpResponse.json({ ...mockApplication, status: 'REJECTED' }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.reject')).toBeInTheDocument(); - }); - - const rejectButton = screen.getByText('jobApplicationReview.reject'); - await user.click(rejectButton); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1'); - }); - }); - - it('rejects application with feedback', async () => { - const user = setupUserEvent(); - const mockApplication = createMockApplication({ id: 1, status: 'PENDING' }); - let capturedFeedback: string | undefined = ''; - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }), - http.put(`${API_BASE_URL}/applications/1/reject`, async ({ request }) => { - const body = await request.json() as UpdateJobApplicationRequest; - capturedFeedback = body.feedback; - return HttpResponse.json({ ...mockApplication, status: 'REJECTED', feedback: capturedFeedback }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.reject')).toBeInTheDocument(); - }); - - const feedbackTextarea = screen.getByPlaceholderText('jobApplicationReview.feedbackPlaceholder'); - await user.type(feedbackTextarea, 'Not a good fit at this time'); - - const rejectButton = screen.getByText('jobApplicationReview.reject'); - await user.click(rejectButton); - - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith('/employer/jobs/1'); - }); - }); - - it('shows error toast on approval failure', async () => { - const user = setupUserEvent(); - const mockApplication = createMockApplication({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }), - http.put(`${API_BASE_URL}/applications/1/approve`, () => { - return HttpResponse.json({ message: 'Server error' }, { status: 500 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.approve')).toBeInTheDocument(); - }); - - const approveButton = screen.getByText('jobApplicationReview.approve'); - await user.click(approveButton); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.error.approve')).toBeInTheDocument(); - }); - - // Should not navigate on error - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('shows error toast on rejection failure', async () => { - const user = setupUserEvent(); - const mockApplication = createMockApplication({ id: 1 }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }), - http.put(`${API_BASE_URL}/applications/1/reject`, () => { - return HttpResponse.json({ message: 'Server error' }, { status: 500 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.reject')).toBeInTheDocument(); - }); - - const rejectButton = screen.getByText('jobApplicationReview.reject'); - await user.click(rejectButton); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.error.reject')).toBeInTheDocument(); - }); - - // Should not navigate on error - expect(mockNavigate).not.toHaveBeenCalled(); - }); - - it('disables buttons during submission', async () => { - const user = setupUserEvent(); - const mockApplication = createMockApplication({ id: 1 }); - - let resolveApproval: (value: null) => void; - const approvalPromise = new Promise((resolve) => { - resolveApproval = resolve; - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }), - http.put(`${API_BASE_URL}/applications/1/approve`, async () => { - await approvalPromise; - return HttpResponse.json({ ...mockApplication, status: 'APPROVED' }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.approve')).toBeInTheDocument(); - }); - - const approveButton = screen.getByText('jobApplicationReview.approve'); - const rejectButton = screen.getByText('jobApplicationReview.reject'); - - await user.click(approveButton); - - await waitFor(() => { - const processingButtons = screen.getAllByText('jobApplicationReview.processing'); - expect(processingButtons.length).toBeGreaterThan(0); - }); - - expect(approveButton).toBeDisabled(); - expect(rejectButton).toBeDisabled(); - - resolveApproval!(null); - }); - - it('disables feedback textarea during submission', async () => { - const user = setupUserEvent(); - const mockApplication = createMockApplication({ id: 1 }); - - let resolveApproval: (value: null) => void; - const approvalPromise = new Promise((resolve) => { - resolveApproval = resolve; - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }), - http.put(`${API_BASE_URL}/applications/1/approve`, async () => { - await approvalPromise; - return HttpResponse.json({ ...mockApplication, status: 'APPROVED' }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.approve')).toBeInTheDocument(); - }); - - const feedbackTextarea = screen.getByPlaceholderText('jobApplicationReview.feedbackPlaceholder'); - const approveButton = screen.getByText('jobApplicationReview.approve'); - - await user.click(approveButton); - - await waitFor(() => { - expect(feedbackTextarea).toBeDisabled(); - }); - - resolveApproval!(null); - }); - - it('displays error page when application fetch fails', async () => { - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json({ message: 'Application not found' }, { status: 404 }); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.error.title')).toBeInTheDocument(); - }); - - expect(screen.getByText('jobApplicationReview.backToJob')).toBeInTheDocument(); - }); - - it('navigates back to job when Back to Job button is clicked', async () => { - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json({ message: 'Application not found' }, { status: 404 }); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('jobApplicationReview.backToJob')).toBeInTheDocument(); - }); - - const backButton = screen.getByText('jobApplicationReview.backToJob'); - expect(backButton).toHaveAttribute('href', '/employer/jobs/1'); - }); - - it('generates correct avatar initials for applicant name', async () => { - const mockApplication = createMockApplication({ - id: 1, - applicantName: 'Sarah Jane Smith', - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('SJS')).toBeInTheDocument(); - }); - }); - - it('formats application date correctly - today', async () => { - const mockApplication = createMockApplication({ - id: 1, - appliedDate: new Date().toISOString(), - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText(mockApplication.applicantName)).toBeInTheDocument(); - }); - - // Check that date text contains the key (since i18n mock returns keys) - expect(screen.getByText(/jobApplicationReview\.applied/)).toBeInTheDocument(); - }); - - it('formats application date correctly - yesterday', async () => { - const mockApplication = createMockApplication({ - id: 1, - appliedDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText(mockApplication.applicantName)).toBeInTheDocument(); - }); - - expect(screen.getByText(/jobApplicationReview\.applied/)).toBeInTheDocument(); - }); - - it('formats application date correctly - days ago', async () => { - // Create a date that is exactly 7 days ago - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - const mockApplication = createMockApplication({ - id: 1, - appliedDate: sevenDaysAgo.toISOString(), - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText(mockApplication.applicantName)).toBeInTheDocument(); - }); - - expect(screen.getByText(/jobApplicationReview\.applied/)).toBeInTheDocument(); - }); - - it('displays existing feedback when present', async () => { - const mockApplication = createMockApplication({ - id: 1, - feedback: 'Previously reviewed: strong technical skills but lacks team experience.', - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText('Previously reviewed: strong technical skills but lacks team experience.')).toBeInTheDocument(); - }); - - expect(screen.getByText('jobApplicationReview.existingFeedback')).toBeInTheDocument(); - }); - - it('does not display existing feedback section when not present', async () => { - const mockApplication = createMockApplication({ - id: 1, - feedback: undefined, - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText(mockApplication.applicantName)).toBeInTheDocument(); - }); - - expect(screen.queryByText('jobApplicationReview.existingFeedback')).not.toBeInTheDocument(); - }); - - it('displays application status correctly', async () => { - const mockApplication = createMockApplication({ - id: 1, - status: 'PENDING', - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText(/PENDING/i)).toBeInTheDocument(); - }); - }); - - it('handles very long cover letters correctly', async () => { - const longCoverLetter = 'A'.repeat(5000); - const mockApplication = createMockApplication({ - id: 1, - coverLetter: longCoverLetter, - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect(screen.getByText(longCoverLetter)).toBeInTheDocument(); - }); - }); - - it('preserves whitespace in cover letter and special needs', async () => { - const multilineCoverLetter = 'Dear Hiring Manager,\n\nI am excited to apply.\n\nBest regards,\nJohn'; - const mockApplication = createMockApplication({ - id: 1, - coverLetter: multilineCoverLetter, - specialNeeds: 'Line 1\nLine 2\nLine 3', - }); - - server.use( - http.get(`${API_BASE_URL}/applications/1`, () => { - return HttpResponse.json(mockApplication); - }), - http.get(`${API_BASE_URL}/applications/1/cv`, () => { - return HttpResponse.json(null, { status: 404 }); - }) - ); - - renderWithProviders(, { - initialEntries: ['/employer/jobs/1/applications/1'] - }); - - await waitFor(() => { - expect( - screen.getByText((_content, element) => element?.textContent === multilineCoverLetter) - ).toBeInTheDocument(); - }); - - expect( - screen.getByText((_content, element) => element?.textContent === 'Line 1\nLine 2\nLine 3') - ).toBeInTheDocument(); - }); -}); diff --git a/apps/jobboard-frontend/src/pages/__tests__/profile/ProfilePage.test.tsx b/apps/jobboard-frontend/src/pages/__tests__/profile/ProfilePage.test.tsx deleted file mode 100644 index 1233481d..00000000 --- a/apps/jobboard-frontend/src/pages/__tests__/profile/ProfilePage.test.tsx +++ /dev/null @@ -1,2138 +0,0 @@ -// import { screen, waitFor } from '@testing-library/react'; -// import { http, HttpResponse } from 'msw'; -// import ProfilePage from '../../ProfilePage'; -// import { it, describe, beforeEach, expect } from 'vitest'; -// import { renderWithProviders, setupUserEvent } from '@/test/utils'; -// import { server } from '@/test/setup'; -// import { API_BASE_URL } from '@/test/handlers'; -// import { useAuthStore } from '@/stores/authStore'; - -import { it, expect } from 'vitest'; - -it.skip('skipping profile page tests', () => { - expect(true).toBe(true); -}); - -// // Mock profile data that matches the handlers -// const mockProfile = { -// id: 1, -// userId: 1, -// firstName: 'John', -// lastName: 'Doe', -// bio: 'Software Engineer with 5 years of experience', -// imageUrl: 'https://example.com/profile.jpg', -// educations: [ -// { -// id: 1, -// school: 'University of Example', -// degree: 'Bachelor of Science', -// field: 'Computer Science', -// startDate: '2018-09-01', -// endDate: '2022-06-15', -// description: 'Studied software engineering and computer science fundamentals' -// } -// ], -// experiences: [ -// { -// id: 1, -// company: 'Tech Corp', -// position: 'Software Engineer', -// description: 'Developed web applications using React and Node.js', -// startDate: '2022-07-01', -// endDate: null -// } -// ], -// skills: [ -// { id: 1, name: 'JavaScript', level: 'Advanced' }, -// { id: 2, name: 'React', level: 'Advanced' }, -// { id: 3, name: 'TypeScript', level: 'Intermediate' } -// ], -// interests: [ -// { id: 1, name: 'Web Development' }, -// { id: 2, name: 'Machine Learning' } -// ], -// badges: [], -// createdAt: '2024-01-01T00:00:00Z', -// updatedAt: '2024-01-15T10:30:00Z' -// }; - -// describe('ProfilePage', () => { -// beforeEach(() => { -// // Set up authenticated user state for tests -// useAuthStore.setState({ -// user: { -// id: 1, -// username: 'johndoe', -// email: 'john@example.com', -// role: 'ROLE_JOBSEEKER', -// }, -// accessToken: 'mock-jwt-token', -// refreshToken: null, -// isAuthenticated: true, -// }); -// }); - -// it('renders profile page with user information', async () => { -// renderWithProviders(); - -// // Wait for loading to complete and check that user name is displayed -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check if bio is rendered -// expect(screen.getByText('Software Engineer with 5 years of experience')).toBeInTheDocument(); - -// // Check if tab buttons are present - get all instances and verify we have the expected number -// expect(screen.getAllByText('About')).toHaveLength(2); // Tab and section heading -// expect(screen.getByText('Activity')).toBeInTheDocument(); -// expect(screen.getAllByText('Posts')).toHaveLength(2); // Tab and stats label -// }); - -// it('displays skills in about tab', async () => { -// renderWithProviders(); - -// // Wait for loading to complete -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check skills section - these should be visible since about tab is default -// expect(screen.getByText('JavaScript')).toBeInTheDocument(); -// expect(screen.getByText('React')).toBeInTheDocument(); -// expect(screen.getByText('TypeScript')).toBeInTheDocument(); -// }); - -// it('switches between tabs correctly', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for loading to complete -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find and click the activity tab button (use getByRole to be more specific) -// const activityTab = screen.getByRole('button', { name: 'Activity' }); -// await user.click(activityTab); - -// // Find and click the posts tab button -// const postsTab = screen.getByRole('button', { name: 'Posts' }); -// await user.click(postsTab); - -// // Switch back to about tab -// const aboutTab = screen.getByRole('button', { name: 'About' }); -// await user.click(aboutTab); -// }); - -// it('shows create profile modal when profile does not exist', async () => { -// // Mock 404 response for profile not found -// server.use( -// http.get(`${API_BASE_URL}/profile`, () => -// HttpResponse.json( -// { code: 'PROFILE_NOT_FOUND', message: 'Profile not found' }, -// { status: 404 } -// ) -// ) -// ); - -// renderWithProviders(); - -// // Wait for the create profile modal to appear by checking for the heading -// await waitFor(() => { -// expect(screen.getByRole('heading', { name: createProfileTitle })).toBeInTheDocument(); -// }); - -// // Check for form fields in the modal -// expect(screen.getByText('First Name')).toBeInTheDocument(); -// expect(screen.getByText('Last Name')).toBeInTheDocument(); -// }); - -// it('handles profile loading error gracefully', async () => { -// // Mock server error -// server.use( -// http.get(`${API_BASE_URL}/profile`, () => -// HttpResponse.json( -// { message: 'Internal server error' }, -// { status: 500 } -// ) -// ) -// ); - -// renderWithProviders(); - -// // Wait for loading to finish - the page should not crash and should handle the error -// await waitFor(() => { -// // Since error handling shows a toast, we can't easily check for the toast text -// // but we can ensure the component doesn't crash by checking the page structure -// expect(document.body).toBeInTheDocument(); -// }, { timeout: 3000 }); -// }); - -// it('renders profile sections with proper structure', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check for profile sections by their headings -// expect(screen.getByRole('heading', { name: 'About' })).toBeInTheDocument(); -// expect(screen.getByRole('heading', { name: 'Experience' })).toBeInTheDocument(); -// expect(screen.getByRole('heading', { name: 'Education' })).toBeInTheDocument(); -// expect(screen.getByRole('heading', { name: 'Skills' })).toBeInTheDocument(); -// expect(screen.getByRole('heading', { name: 'Interests' })).toBeInTheDocument(); -// }); - -// it('displays profile data from API response', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check data from mock profile -// expect(screen.getByText('Software Engineer with 5 years of experience')).toBeInTheDocument(); - -// // Check for experience data (should be in heading format) -// expect(screen.getByText('Software Engineer, Tech Corp')).toBeInTheDocument(); - -// // Check for education data -// expect(screen.getByText('Bachelor of Science in Computer Science, University of Example')).toBeInTheDocument(); - -// // Check for interests -// expect(screen.getByText('Web Development')).toBeInTheDocument(); -// expect(screen.getByText('Machine Learning')).toBeInTheDocument(); -// }); - -// it('renders profile image edit button', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Verify that the edit profile image button is present -// const editImageButton = screen.getByLabelText(editProfileImageLabel); -// expect(editImageButton).toBeInTheDocument(); - -// // Verify the button is clickable (basic interaction test) -// expect(editImageButton).not.toBeDisabled(); -// }); - -// it('displays user avatar with initials when no profile image exists', async () => { -// // Update mock to not have an image URL -// server.use( -// http.get(`${API_BASE_URL}/profile`, () => -// HttpResponse.json({ -// ...mockProfile, -// imageUrl: undefined, -// }, { status: 200 }) -// ) -// ); - -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that avatar fallback with initials is displayed -// // The avatar should show initials as fallback when no image exists -// // Use data attribute to identify the specific avatar fallback element -// const avatarElement = document.querySelector('[data-slot="avatar-fallback"]'); -// expect(avatarElement).toBeInTheDocument(); - -// // Verify the avatar shows the correct initials when no image is provided -// expect(avatarElement?.textContent?.trim()).toMatch(/^J\s*D$/); // Matches "J D" or "JD" -// }); - -// it('provides API endpoints for profile image operations', async () => { -// // Test that our mock handlers are working correctly -// // This verifies the API structure even if the UI isn't fully implemented - -// // Test successful image upload endpoint -// const formData = new FormData(); -// const file = new File(['test content'], 'test.jpg', { type: 'image/jpeg' }); -// formData.append('file', file); - -// const uploadResponse = await fetch(`${API_BASE_URL}/profile/image`, { -// method: 'POST', -// body: formData, -// }); - -// expect(uploadResponse.status).toBe(200); -// const uploadData = await uploadResponse.json(); -// expect(uploadData).toHaveProperty('imageUrl'); -// expect(uploadData).toHaveProperty('updatedAt'); - -// // Test image deletion endpoint -// const deleteResponse = await fetch(`${API_BASE_URL}/profile/image`, { -// method: 'DELETE', -// }); - -// expect(deleteResponse.status).toBe(204); -// }); - -// describe('Work Experience CRUD Operations', () => { -// it('displays existing work experiences in the list', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that the existing work experience is displayed -// expect(screen.getByText('Software Engineer, Tech Corp')).toBeInTheDocument(); -// expect(screen.getByText('Developed web applications using React and Node.js')).toBeInTheDocument(); -// }); - -// it('opens add work experience modal when add button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile and experience section to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// expect(screen.getByRole('heading', { name: 'Experience' })).toBeInTheDocument(); -// }); - -// // Find the Experience section by its heading and then find its Add button -// const experienceHeading = screen.getByRole('heading', { name: 'Experience' }); -// const experienceSection = experienceHeading.closest('section'); -// const addExperienceButton = experienceSection?.querySelector('button') as HTMLButtonElement; - -// expect(addExperienceButton).toBeInTheDocument(); -// await user.click(addExperienceButton); - -// // Check that the add experience modal appears (the modal heading appears) -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Experience' || -// h.textContent === 'profile.experience.modal.addTitle' -// ); -// expect(modalHeading).toBeTruthy(); -// }); -// }); - -// it('successfully adds a new work experience', async () => { -// // Mock successful POST response -// server.use( -// http.post(`${API_BASE_URL}/profile/experience`, () => -// HttpResponse.json({ -// id: 2, -// company: 'New Tech Company', -// position: 'Senior Developer', -// description: 'Lead development of new features', -// startDate: '2023-01-01', -// endDate: null -// }, { status: 201 }) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Experience section by its heading and then find its Add button -// const experienceHeading = screen.getByRole('heading', { name: 'Experience' }); -// const experienceSection = experienceHeading.closest('section'); -// const addExperienceButton = experienceSection?.querySelector('button') as HTMLButtonElement; - -// expect(addExperienceButton).toBeInTheDocument(); -// await user.click(addExperienceButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Experience' || h.textContent === 'profile.experience.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Simply verify the modal opened and try to submit (API will be mocked) -// // The test focuses on API integration, not form validation -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// if (saveButton) { -// await user.click(saveButton); -// } - -// // Verify the modal closes -// await waitFor(() => { -// expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); -// }); -// }); - -// it('verifies edit work experience functionality is available', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check if Experience section exists - if not, that's fine, just verify basic profile functionality -// const experienceHeading = screen.queryByRole('heading', { name: 'Experience' }); - -// if (experienceHeading) { -// // Experience section exists, verify it's properly structured -// const experienceSection = experienceHeading.closest('section'); -// expect(experienceSection).toBeInTheDocument(); - -// // This test verifies the experience section is available for functionality -// expect(experienceHeading).toBeInTheDocument(); -// } else { -// // Experience section doesn't exist yet - that's okay, just verify the profile loads -// expect(screen.getByText('John')).toBeInTheDocument(); -// expect(screen.getByText('Doe')).toBeInTheDocument(); -// } -// }); - -// it('supports updating work experience via API', async () => { -// // Test the update experience API endpoint directly -// const updatedExperience = { -// id: 1, -// company: 'Tech Corp Updated', -// position: 'Senior Software Engineer', -// description: 'Updated description for the role', -// startDate: '2022-07-01', -// endDate: null, -// current: true -// }; - -// // Test the API call -// const response = await fetch(`${API_BASE_URL}/profile/experience/1`, { -// method: 'PUT', -// headers: { 'Content-Type': 'application/json' }, -// body: JSON.stringify(updatedExperience) -// }); - -// expect(response.ok).toBe(true); -// const result = await response.json(); -// expect(result.company).toBe('Tech Corp Updated'); -// expect(result.position).toBe('Senior Software Engineer'); - -// // Also verify the UI renders profile data properly -// renderWithProviders(); - -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Verify profile is loaded successfully -// expect(screen.getByText('Software Engineer with 5 years of experience')).toBeInTheDocument(); -// }); - -// it('successfully deletes a work experience', async () => { -// // Mock successful DELETE response -// server.use( -// http.delete(`${API_BASE_URL}/profile/experience/1`, () => -// HttpResponse.json({}, { status: 204 }) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find and click the trash delete button -// const deleteButtons = screen.getAllByRole('button'); -// const deleteButton = deleteButtons.find(button => { -// const svg = button.querySelector('svg'); -// return svg && svg.classList.contains('lucide-trash-2'); -// }); - -// expect(deleteButton).toBeInTheDocument(); -// await user.click(deleteButton!); - -// // The delete should happen immediately based on the ProfilePage implementation -// // Verify the experience was removed (check that the API was called) -// await waitFor(() => { -// // Since we're mocking the API and the component refetches the profile, -// // we just verify the delete function doesn't throw an error -// expect(true).toBe(true); -// }); -// }); - -// it('handles work experience form validation errors', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Experience section by its heading and then find its Add button -// const experienceHeading = screen.getByRole('heading', { name: 'Experience' }); -// const experienceSection = experienceHeading.closest('section'); -// const addExperienceButton = experienceSection?.querySelector('button') as HTMLButtonElement; - -// expect(addExperienceButton).toBeInTheDocument(); -// await user.click(addExperienceButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Experience' || h.textContent === 'profile.experience.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Try to find a submit button (could be Add/Save with various text or translation keys) -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// // Just verify modal is open - form validation varies by implementation -// expect(saveButton).toBeInTheDocument(); -// }); - -// it('handles API errors when adding work experience', async () => { -// // Mock error response -// server.use( -// http.post(`${API_BASE_URL}/profile/experience`, () => -// HttpResponse.json( -// { message: 'Failed to create work experience' }, -// { status: 400 } -// ) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Experience section by its heading and then find its Add button -// const experienceHeading = screen.getByRole('heading', { name: 'Experience' }); -// const experienceSection = experienceHeading.closest('section'); -// const addExperienceButton = experienceSection?.querySelector('button') as HTMLButtonElement; - -// expect(addExperienceButton).toBeInTheDocument(); -// await user.click(addExperienceButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Experience' || h.textContent === 'profile.experience.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Focus on testing API error handling, not form validation -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// if (saveButton) { -// await user.click(saveButton); -// } - -// // The error should be handled gracefully (modal stays open for retry) -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Experience' || h.textContent === 'profile.experience.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); -// }); - -// it('handles API errors when updating work experience', async () => { -// // Test API error handling for update endpoint -// const updatedExperience = { -// id: 1, -// company: 'Updated Company', -// position: 'Updated Position', -// description: 'Updated description', -// startDate: '2022-01-01', -// endDate: null, -// current: true -// }; - -// // Mock error response first -// server.use( -// http.put(`${API_BASE_URL}/profile/experience/1`, () => -// HttpResponse.json( -// { message: 'Failed to update work experience' }, -// { status: 400 } -// ) -// ) -// ); - -// // Test the API call returns error -// const response = await fetch(`${API_BASE_URL}/profile/experience/1`, { -// method: 'PUT', -// headers: { 'Content-Type': 'application/json' }, -// body: JSON.stringify(updatedExperience) -// }); - -// expect(response.ok).toBe(false); -// expect(response.status).toBe(400); - -// const error = await response.json(); -// expect(error.message).toBe('Failed to update work experience'); - -// // Verify the UI still works normally -// renderWithProviders(); -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that the profile bio is displayed properly -// expect(screen.getByText('Software Engineer with 5 years of experience')).toBeInTheDocument(); -// }); - -// it('handles API errors when deleting work experience', async () => { -// // Mock error response -// server.use( -// http.delete(`${API_BASE_URL}/profile/experience/1`, () => -// HttpResponse.json( -// { message: 'Failed to delete work experience' }, -// { status: 400 } -// ) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find and click the trash delete button -// const deleteButtons = screen.getAllByRole('button'); -// const deleteButton = deleteButtons.find(button => { -// const svg = button.querySelector('svg'); -// return svg && svg.classList.contains('lucide-trash-2'); -// }); - -// expect(deleteButton).toBeInTheDocument(); -// await user.click(deleteButton!); - -// // The error should be handled gracefully -// await waitFor(() => { -// // Just verify the component doesn't crash -// expect(document.body).toBeInTheDocument(); -// }); -// }); - -// it('cancels add work experience operation when cancel button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Test cancel on add modal -// // Find the Experience section by its heading and then find its Add button -// const experienceHeading = screen.getByRole('heading', { name: 'Experience' }); -// const experienceSection = experienceHeading.closest('section'); -// const addExperienceButton = experienceSection?.querySelector('button') as HTMLButtonElement; - -// expect(addExperienceButton).toBeInTheDocument(); -// await user.click(addExperienceButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Experience' || h.textContent === 'profile.experience.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// const cancelButton = screen.getByRole('button', { name: 'Cancel' }); -// await user.click(cancelButton); - -// await waitFor(() => { -// const headings = screen.queryAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Experience' || h.textContent === 'profile.experience.modal.addTitle' -// ); -// expect(modalHeading).toBeUndefined(); -// }); - -// // Verify the profile is still displayed normally -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// it('validates date ranges in work experience form', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Experience section by its heading and then find its Add button -// const experienceHeading = screen.getByRole('heading', { name: 'Experience' }); -// const experienceSection = experienceHeading.closest('section'); -// const addExperienceButton = experienceSection?.querySelector('button') as HTMLButtonElement; - -// expect(addExperienceButton).toBeInTheDocument(); -// await user.click(addExperienceButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Experience' || h.textContent === 'profile.experience.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // This test verifies the modal opens - date validation is implementation-dependent -// // Just verify that the form is functional -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.experience.modal.submitAdd') -// ); - -// // The button should still be clickable, validation might prevent submission -// if (saveButton) { -// expect(saveButton).toBeInTheDocument(); -// } else { -// // If no save button found, just verify modal is open -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Experience' || h.textContent === 'profile.experience.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// } -// }); -// }); - -// describe('Education CRUD Operations', () => { -// it('displays existing education entries in the list', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that the existing education is displayed -// expect(screen.getByText('Bachelor of Science in Computer Science, University of Example')).toBeInTheDocument(); -// expect(screen.getByText('Studied software engineering and computer science fundamentals')).toBeInTheDocument(); -// }); - -// it('opens add education modal when add button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile and education section to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// expect(screen.getByRole('heading', { name: 'Education' })).toBeInTheDocument(); -// }); - -// // Find the Education section by its heading and then find its Add button -// const educationHeading = screen.getByRole('heading', { name: 'Education' }); -// const educationSection = educationHeading.closest('section'); -// const addEducationButton = educationSection?.querySelector('button') as HTMLButtonElement; - -// expect(addEducationButton).toBeInTheDocument(); -// await user.click(addEducationButton); - -// // Check that the add education modal appears (the modal heading appears) -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || -// h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeTruthy(); -// }); -// }); - -// it('successfully adds a new education entry', async () => { -// // Mock successful POST response -// server.use( -// http.post(`${API_BASE_URL}/profile/education`, () => -// HttpResponse.json({ -// id: 2, -// school: 'MIT', -// degree: 'Master of Science', -// field: 'Artificial Intelligence', -// startDate: '2023-09-01', -// endDate: '2025-06-15', -// description: 'Advanced studies in machine learning and AI' -// }, { status: 201 }) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Education section by its heading and then find its Add button -// const educationHeading = screen.getByRole('heading', { name: 'Education' }); -// const educationSection = educationHeading.closest('section'); -// const addEducationButton = educationSection?.querySelector('button') as HTMLButtonElement; - -// expect(addEducationButton).toBeInTheDocument(); -// await user.click(addEducationButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Simply verify the modal opened and try to submit (API will be mocked) -// // The test focuses on API integration, not form validation -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// if (saveButton) { -// await user.click(saveButton); -// } - -// // Verify the modal closes -// await waitFor(() => { -// expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); -// }); -// }); - -// it('verifies edit education functionality is available', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check if Education section exists - if not, that's fine, just verify basic profile functionality -// const educationHeading = screen.queryByRole('heading', { name: 'Education' }); - -// if (educationHeading) { -// // Education section exists, verify it's properly structured -// const educationSection = educationHeading.closest('section'); -// expect(educationSection).toBeInTheDocument(); - -// // This test verifies the education section is available for functionality -// expect(educationHeading).toBeInTheDocument(); -// } else { -// // Education section doesn't exist yet - that's okay, just verify the profile loads -// expect(screen.getByText('John')).toBeInTheDocument(); -// expect(screen.getByText('Doe')).toBeInTheDocument(); -// } -// }); - -// it('supports updating education via API', async () => { -// // Test the update education API endpoint directly -// const updatedEducation = { -// id: 1, -// school: 'University of Example Updated', -// degree: 'Master of Science', -// field: 'Computer Science', -// startDate: '2018-09-01', -// endDate: '2022-06-15', -// description: 'Updated description for education', -// current: false -// }; - -// // Test the API call -// const response = await fetch(`${API_BASE_URL}/profile/education/1`, { -// method: 'PUT', -// headers: { 'Content-Type': 'application/json' }, -// body: JSON.stringify(updatedEducation) -// }); - -// expect(response.ok).toBe(true); -// const result = await response.json(); -// expect(result.school).toBe('University of Example Updated'); -// expect(result.degree).toBe('Master of Science'); - -// // Also verify the UI renders profile data properly -// renderWithProviders(); - -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Verify profile is loaded successfully -// expect(screen.getByText('Software Engineer with 5 years of experience')).toBeInTheDocument(); -// }); - -// it('successfully deletes an education entry', async () => { -// // Mock successful DELETE response -// server.use( -// http.delete(`${API_BASE_URL}/profile/education/1`, () => -// HttpResponse.json({}, { status: 204 }) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find and click the trash delete button in education section -// const deleteButtons = screen.getAllByRole('button'); -// const deleteButton = deleteButtons.find(button => { -// const svg = button.querySelector('svg'); -// return svg && svg.classList.contains('lucide-trash-2'); -// }); - -// expect(deleteButton).toBeInTheDocument(); -// await user.click(deleteButton!); - -// // The delete should happen immediately based on the ProfilePage implementation -// // Verify the education was removed (check that the API was called) -// await waitFor(() => { -// // Since we're mocking the API and the component refetches the profile, -// // we just verify the delete function doesn't throw an error -// expect(true).toBe(true); -// }); -// }); - -// it('handles education form validation errors', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Education section by its heading and then find its Add button -// const educationHeading = screen.getByRole('heading', { name: 'Education' }); -// const educationSection = educationHeading.closest('section'); -// const addEducationButton = educationSection?.querySelector('button') as HTMLButtonElement; - -// expect(addEducationButton).toBeInTheDocument(); -// await user.click(addEducationButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Try to find a submit button (could be Add/Save with various text or translation keys) -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// // Just verify modal is open - form validation varies by implementation -// expect(saveButton).toBeInTheDocument(); -// }); - -// it('handles API errors when adding education', async () => { -// // Mock error response -// server.use( -// http.post(`${API_BASE_URL}/profile/education`, () => -// HttpResponse.json( -// { message: 'Failed to create education entry' }, -// { status: 400 } -// ) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Education section by its heading and then find its Add button -// const educationHeading = screen.getByRole('heading', { name: 'Education' }); -// const educationSection = educationHeading.closest('section'); -// const addEducationButton = educationSection?.querySelector('button') as HTMLButtonElement; - -// expect(addEducationButton).toBeInTheDocument(); -// await user.click(addEducationButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Focus on testing API error handling, not form validation -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// if (saveButton) { -// await user.click(saveButton); -// } - -// // The error should be handled gracefully (modal stays open for retry) -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); -// }); - -// it('handles API errors when updating education', async () => { -// // Test API error handling for update endpoint -// const updatedEducation = { -// id: 1, -// school: 'Updated University', -// degree: 'Updated Degree', -// field: 'Updated Field', -// startDate: '2018-01-01', -// endDate: '2022-01-01', -// description: 'Updated description', -// current: false -// }; - -// // Mock error response first -// server.use( -// http.put(`${API_BASE_URL}/profile/education/1`, () => -// HttpResponse.json( -// { message: 'Failed to update education entry' }, -// { status: 400 } -// ) -// ) -// ); - -// // Test the API call returns error -// const response = await fetch(`${API_BASE_URL}/profile/education/1`, { -// method: 'PUT', -// headers: { 'Content-Type': 'application/json' }, -// body: JSON.stringify(updatedEducation) -// }); - -// expect(response.ok).toBe(false); -// expect(response.status).toBe(400); - -// const error = await response.json(); -// expect(error.message).toBe('Failed to update education entry'); - -// // Verify the UI still works normally -// renderWithProviders(); -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that the profile bio is displayed properly -// expect(screen.getByText('Software Engineer with 5 years of experience')).toBeInTheDocument(); -// }); - -// it('handles API errors when deleting education', async () => { -// // Mock error response -// server.use( -// http.delete(`${API_BASE_URL}/profile/education/1`, () => -// HttpResponse.json( -// { message: 'Failed to delete education entry' }, -// { status: 400 } -// ) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find and click the trash delete button -// const deleteButtons = screen.getAllByRole('button'); -// const deleteButton = deleteButtons.find(button => { -// const svg = button.querySelector('svg'); -// return svg && svg.classList.contains('lucide-trash-2'); -// }); - -// expect(deleteButton).toBeInTheDocument(); -// await user.click(deleteButton!); - -// // The error should be handled gracefully -// await waitFor(() => { -// // Just verify the component doesn't crash -// expect(document.body).toBeInTheDocument(); -// }); -// }); - -// it('cancels add education operation when cancel button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Test cancel on add modal -// // Find the Education section by its heading and then find its Add button -// const educationHeading = screen.getByRole('heading', { name: 'Education' }); -// const educationSection = educationHeading.closest('section'); -// const addEducationButton = educationSection?.querySelector('button') as HTMLButtonElement; - -// expect(addEducationButton).toBeInTheDocument(); -// await user.click(addEducationButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// const cancelButton = screen.getByRole('button', { name: 'Cancel' }); -// await user.click(cancelButton); - -// await waitFor(() => { -// const headings = screen.queryAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeUndefined(); -// }); - -// // Verify the profile is still displayed normally -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// it('validates date ranges in education form', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Education section by its heading and then find its Add button -// const educationHeading = screen.getByRole('heading', { name: 'Education' }); -// const educationSection = educationHeading.closest('section'); -// const addEducationButton = educationSection?.querySelector('button') as HTMLButtonElement; - -// expect(addEducationButton).toBeInTheDocument(); -// await user.click(addEducationButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // This test verifies the modal opens - date validation is implementation-dependent -// // Just verify that the form is functional -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.education.modal.submitAdd') -// ); - -// // The button should still be clickable, validation might prevent submission -// if (saveButton) { -// expect(saveButton).toBeInTheDocument(); -// } else { -// // If no save button found, just verify modal is open -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// } -// }); - -// it('handles current education toggle functionality', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Education section by its heading and then find its Add button -// const educationHeading = screen.getByRole('heading', { name: 'Education' }); -// const educationSection = educationHeading.closest('section'); -// const addEducationButton = educationSection?.querySelector('button') as HTMLButtonElement; - -// expect(addEducationButton).toBeInTheDocument(); -// await user.click(addEducationButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Education' || h.textContent === 'profile.education.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Check if there's a "Currently studying" or similar toggle -// // This is implementation-dependent, so we just verify the modal structure -// const checkboxes = screen.queryAllByRole('checkbox'); -// const currentToggle = checkboxes.find(checkbox => { -// const label = checkbox.closest('label'); -// return label?.textContent?.toLowerCase().includes('current') || -// label?.textContent?.toLowerCase().includes('studying'); -// }); - -// if (currentToggle) { -// // If toggle exists, test its functionality -// expect(currentToggle).toBeInTheDocument(); -// await user.click(currentToggle); -// // Verify toggle state changed (implementation varies) -// } else { -// // If no current toggle, just verify modal is functional -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') -// ); -// expect(saveButton).toBeInTheDocument(); -// } -// }); -// }); - -// describe('Skills CRUD Operations', () => { -// it('displays existing skills in the list', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that the existing skills are displayed -// expect(screen.getByText('JavaScript')).toBeInTheDocument(); -// expect(screen.getByText('React')).toBeInTheDocument(); -// expect(screen.getByText('TypeScript')).toBeInTheDocument(); - -// // Check skill levels are displayed (they might be in different elements or combined) -// // Use getAllByText to handle multiple instances and check if any contain the level -// const advancedElements = screen.queryAllByText(/Advanced/i); -// const intermediateElements = screen.queryAllByText(/Intermediate/i); - -// // Check if skill levels are displayed, if not that's ok - skills section is still functional -// if (advancedElements.length > 0 || intermediateElements.length > 0) { -// // Skill levels are implemented and displayed -// expect(advancedElements.length + intermediateElements.length).toBeGreaterThan(0); -// } else { -// // Skill levels might not be implemented in UI yet, just verify skills are shown -// expect(screen.getByText('JavaScript')).toBeInTheDocument(); -// } -// }); - -// it('opens add skill modal when add button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile and skills section to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// expect(screen.getByRole('heading', { name: 'Skills' })).toBeInTheDocument(); -// }); - -// // Find the Skills section by its heading and then find its Add button -// const skillsHeading = screen.getByRole('heading', { name: 'Skills' }); -// const skillsSection = skillsHeading.closest('section'); -// const addSkillButton = skillsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addSkillButton).toBeInTheDocument(); -// await user.click(addSkillButton); - -// // Check that the add skill modal appears (the modal heading appears) -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Skill' || -// h.textContent === 'profile.skill.modal.addTitle' -// ); -// expect(modalHeading).toBeTruthy(); -// }); -// }); - -// it('successfully adds a new skill', async () => { -// // Mock successful POST response -// server.use( -// http.post(`${API_BASE_URL}/profile/skill`, () => -// HttpResponse.json({ -// id: 4, -// name: 'Node.js', -// level: 'Advanced' -// }, { status: 201 }) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Skills section by its heading and then find its Add button -// const skillsHeading = screen.getByRole('heading', { name: 'Skills' }); -// const skillsSection = skillsHeading.closest('section'); -// const addSkillButton = skillsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addSkillButton).toBeInTheDocument(); -// await user.click(addSkillButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Skill' || h.textContent === 'profile.skill.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Simply verify the modal opened and try to submit (API will be mocked) -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// if (saveButton) { -// await user.click(saveButton); -// } - -// // Verify the modal closes -// await waitFor(() => { -// expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); -// }); -// }); - -// it('verifies edit skill functionality when available', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check if Skills section exists and verify it's properly structured -// const skillsHeading = screen.queryByRole('heading', { name: 'Skills' }); - -// if (skillsHeading) { -// // Skills section exists, verify it's properly structured -// const skillsSection = skillsHeading.closest('section'); -// expect(skillsSection).toBeInTheDocument(); - -// // Verify skills are displayed -// expect(screen.getByText('JavaScript')).toBeInTheDocument(); -// expect(screen.getByText('React')).toBeInTheDocument(); -// expect(screen.getByText('TypeScript')).toBeInTheDocument(); - -// // This test verifies the skills section is available and functional -// expect(skillsHeading).toBeInTheDocument(); -// } else { -// // Skills section doesn't exist yet - that's okay, just verify the profile loads -// expect(screen.getByText('John')).toBeInTheDocument(); -// expect(screen.getByText('Doe')).toBeInTheDocument(); -// } -// }); - -// it('supports updating skill via API', async () => { -// // Test the update skill API endpoint directly -// const updatedSkill = { -// id: 1, -// name: 'JavaScript', -// level: 'Expert' -// }; - -// // Test the API call -// const response = await fetch(`${API_BASE_URL}/profile/skill/1`, { -// method: 'PUT', -// headers: { 'Content-Type': 'application/json' }, -// body: JSON.stringify(updatedSkill) -// }); - -// expect(response.ok).toBe(true); -// const result = await response.json(); -// expect(result.name).toBe('JavaScript'); -// expect(result.level).toBe('Expert'); - -// // Also verify the UI renders profile data properly -// renderWithProviders(); - -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Verify profile is loaded successfully -// expect(screen.getByText('Software Engineer with 5 years of experience')).toBeInTheDocument(); -// }); - -// it('handles API errors when adding skill', async () => { -// // Mock error response -// server.use( -// http.post(`${API_BASE_URL}/profile/skill`, () => -// HttpResponse.json( -// { message: 'Failed to create skill' }, -// { status: 400 } -// ) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Skills section by its heading and then find its Add button -// const skillsHeading = screen.getByRole('heading', { name: 'Skills' }); -// const skillsSection = skillsHeading.closest('section'); -// const addSkillButton = skillsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addSkillButton).toBeInTheDocument(); -// await user.click(addSkillButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Skill' || h.textContent === 'profile.skill.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Focus on testing API error handling -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// if (saveButton) { -// await user.click(saveButton); -// } - -// // The error should be handled gracefully (modal stays open for retry) -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Skill' || h.textContent === 'profile.skill.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); -// }); - -// it('handles API errors when updating skill', async () => { -// // Test API error handling for update endpoint -// const updatedSkill = { -// id: 1, -// name: 'Updated Skill', -// level: 'Advanced' -// }; - -// // Mock error response first -// server.use( -// http.put(`${API_BASE_URL}/profile/skill/1`, () => -// HttpResponse.json( -// { message: 'Failed to update skill' }, -// { status: 400 } -// ) -// ) -// ); - -// // Test the API call returns error -// const response = await fetch(`${API_BASE_URL}/profile/skill/1`, { -// method: 'PUT', -// headers: { 'Content-Type': 'application/json' }, -// body: JSON.stringify(updatedSkill) -// }); - -// expect(response.ok).toBe(false); -// expect(response.status).toBe(400); - -// const error = await response.json(); -// expect(error.message).toBe('Failed to update skill'); - -// // Verify the UI still works normally -// renderWithProviders(); -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that the profile skills are displayed properly -// expect(screen.getByText('JavaScript')).toBeInTheDocument(); -// }); - -// it('cancels add skill operation when cancel button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Test cancel on add modal -// // Find the Skills section by its heading and then find its Add button -// const skillsHeading = screen.getByRole('heading', { name: 'Skills' }); -// const skillsSection = skillsHeading.closest('section'); -// const addSkillButton = skillsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addSkillButton).toBeInTheDocument(); -// await user.click(addSkillButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Skill' || h.textContent === 'profile.skill.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// const cancelButton = screen.getByRole('button', { name: 'Cancel' }); -// await user.click(cancelButton); - -// await waitFor(() => { -// const headings = screen.queryAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Skill' || h.textContent === 'profile.skill.modal.addTitle' -// ); -// expect(modalHeading).toBeUndefined(); -// }); - -// // Verify the profile is still displayed normally -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// it('validates skill level options', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Skills section by its heading and then find its Add button -// const skillsHeading = screen.getByRole('heading', { name: 'Skills' }); -// const skillsSection = skillsHeading.closest('section'); -// const addSkillButton = skillsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addSkillButton).toBeInTheDocument(); -// await user.click(addSkillButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Skill' || h.textContent === 'profile.skill.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // This test verifies the modal opens - level validation is implementation-dependent -// // Just verify that the form is functional -// const selects = screen.queryAllByRole('combobox'); -// const levelSelect = selects.find(select => -// select.getAttribute('name')?.includes('level') || -// select.getAttribute('id')?.includes('level') -// ); - -// if (levelSelect) { -// expect(levelSelect).toBeInTheDocument(); -// } else { -// // If no level select found, just verify modal is open -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Skill' || h.textContent === 'profile.skill.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// } -// }); -// }); - -// describe('Interests CRUD Operations', () => { -// it('displays existing interests in the list', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that the existing interests are displayed -// expect(screen.getByText('Web Development')).toBeInTheDocument(); -// expect(screen.getByText('Machine Learning')).toBeInTheDocument(); -// }); - -// it('opens add interest modal when add button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile and interests section to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// expect(screen.getByRole('heading', { name: 'Interests' })).toBeInTheDocument(); -// }); - -// // Find the Interests section by its heading and then find its Add button -// const interestsHeading = screen.getByRole('heading', { name: 'Interests' }); -// const interestsSection = interestsHeading.closest('section'); -// const addInterestButton = interestsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addInterestButton).toBeInTheDocument(); -// await user.click(addInterestButton); - -// // Check that the add interest modal appears (the modal heading appears) -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Interest' || -// h.textContent === 'profile.interest.modal.addTitle' -// ); -// expect(modalHeading).toBeTruthy(); -// }); -// }); - -// it('successfully adds a new interest', async () => { -// // Mock successful POST response -// server.use( -// http.post(`${API_BASE_URL}/profile/interest`, () => -// HttpResponse.json({ -// id: 3, -// name: 'Data Science' -// }, { status: 201 }) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Interests section by its heading and then find its Add button -// const interestsHeading = screen.getByRole('heading', { name: 'Interests' }); -// const interestsSection = interestsHeading.closest('section'); -// const addInterestButton = interestsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addInterestButton).toBeInTheDocument(); -// await user.click(addInterestButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Interest' || h.textContent === 'profile.interest.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Simply verify the modal opened and try to submit (API will be mocked) -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// if (saveButton) { -// await user.click(saveButton); -// } - -// // Verify the modal closes -// await waitFor(() => { -// expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); -// }); -// }); - -// it('verifies edit interest functionality when available', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check if Interests section exists and verify it's properly structured -// const interestsHeading = screen.queryByRole('heading', { name: 'Interests' }); - -// if (interestsHeading) { -// // Interests section exists, verify it's properly structured -// const interestsSection = interestsHeading.closest('section'); -// expect(interestsSection).toBeInTheDocument(); - -// // Verify interests are displayed -// expect(screen.getByText('Web Development')).toBeInTheDocument(); -// expect(screen.getByText('Machine Learning')).toBeInTheDocument(); - -// // This test verifies the interests section is available and functional -// expect(interestsHeading).toBeInTheDocument(); -// } else { -// // Interests section doesn't exist yet - that's okay, just verify the profile loads -// expect(screen.getByText('John')).toBeInTheDocument(); -// expect(screen.getByText('Doe')).toBeInTheDocument(); -// } -// }); - -// it('supports updating interest via API', async () => { -// // Test the update interest API endpoint directly -// const updatedInterest = { -// id: 1, -// name: 'Full Stack Development' -// }; - -// // Test the API call -// const response = await fetch(`${API_BASE_URL}/profile/interest/1`, { -// method: 'PUT', -// headers: { 'Content-Type': 'application/json' }, -// body: JSON.stringify(updatedInterest) -// }); - -// expect(response.ok).toBe(true); -// const result = await response.json(); -// expect(result.name).toBe('Full Stack Development'); - -// // Also verify the UI renders profile data properly -// renderWithProviders(); - -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Verify profile is loaded successfully -// expect(screen.getByText('Software Engineer with 5 years of experience')).toBeInTheDocument(); -// }); - -// it('handles API errors when adding interest', async () => { -// // Mock error response -// server.use( -// http.post(`${API_BASE_URL}/profile/interest`, () => -// HttpResponse.json( -// { message: 'Failed to create interest' }, -// { status: 400 } -// ) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Interests section by its heading and then find its Add button -// const interestsHeading = screen.getByRole('heading', { name: 'Interests' }); -// const interestsSection = interestsHeading.closest('section'); -// const addInterestButton = interestsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addInterestButton).toBeInTheDocument(); -// await user.click(addInterestButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Interest' || h.textContent === 'profile.interest.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // Focus on testing API error handling -// const buttons = screen.getAllByRole('button'); -// const saveButton = buttons.find(button => -// button.textContent?.includes('Add') || -// button.textContent?.includes('Save') || -// button.textContent?.includes('profile.common.save') -// ); - -// if (saveButton) { -// await user.click(saveButton); -// } - -// // The error should be handled gracefully (modal stays open for retry) -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Interest' || h.textContent === 'profile.interest.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); -// }); - -// it('handles API errors when updating interest', async () => { -// // Test API error handling for update endpoint -// const updatedInterest = { -// id: 1, -// name: 'Updated Interest' -// }; - -// // Mock error response first -// server.use( -// http.put(`${API_BASE_URL}/profile/interest/1`, () => -// HttpResponse.json( -// { message: 'Failed to update interest' }, -// { status: 400 } -// ) -// ) -// ); - -// // Test the API call returns error -// const response = await fetch(`${API_BASE_URL}/profile/interest/1`, { -// method: 'PUT', -// headers: { 'Content-Type': 'application/json' }, -// body: JSON.stringify(updatedInterest) -// }); - -// expect(response.ok).toBe(false); -// expect(response.status).toBe(400); - -// const error = await response.json(); -// expect(error.message).toBe('Failed to update interest'); - -// // Verify the UI still works normally -// renderWithProviders(); -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that the profile interests are displayed properly -// expect(screen.getByText('Web Development')).toBeInTheDocument(); -// }); - -// it('cancels add interest operation when cancel button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Test cancel on add modal -// // Find the Interests section by its heading and then find its Add button -// const interestsHeading = screen.getByRole('heading', { name: 'Interests' }); -// const interestsSection = interestsHeading.closest('section'); -// const addInterestButton = interestsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addInterestButton).toBeInTheDocument(); -// await user.click(addInterestButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Interest' || h.textContent === 'profile.interest.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// const cancelButton = screen.getByRole('button', { name: 'Cancel' }); -// await user.click(cancelButton); - -// await waitFor(() => { -// const headings = screen.queryAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Interest' || h.textContent === 'profile.interest.modal.addTitle' -// ); -// expect(modalHeading).toBeUndefined(); -// }); - -// // Verify the profile is still displayed normally -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// it('validates interest name input', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load and open add modal -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find the Interests section by its heading and then find its Add button -// const interestsHeading = screen.getByRole('heading', { name: 'Interests' }); -// const interestsSection = interestsHeading.closest('section'); -// const addInterestButton = interestsSection?.querySelector('button') as HTMLButtonElement; - -// expect(addInterestButton).toBeInTheDocument(); -// await user.click(addInterestButton); - -// await waitFor(() => { -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Interest' || h.textContent === 'profile.interest.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// }); - -// // This test verifies the modal opens - input validation is implementation-dependent -// // Just verify that the form is functional -// const textInputs = screen.queryAllByRole('textbox'); -// const nameInput = textInputs.find(input => -// input.getAttribute('name')?.includes('name') || -// input.getAttribute('placeholder')?.toLowerCase().includes('interest') -// ); - -// if (nameInput) { -// expect(nameInput).toBeInTheDocument(); -// await user.type(nameInput, 'Test Interest'); -// expect(nameInput).toHaveValue('Test Interest'); -// } else { -// // If no name input found, just verify modal is open -// const headings = screen.getAllByRole('heading'); -// const modalHeading = headings.find(h => -// h.textContent === 'Add Interest' || h.textContent === 'profile.interest.modal.addTitle' -// ); -// expect(modalHeading).toBeInTheDocument(); -// } -// }); -// }); - -// describe('Delete All Data Functionality', () => { -// it('displays danger zone section with Delete All Data button', async () => { -// renderWithProviders(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Check that the Danger Zone section is present -// expect(screen.getByText(dangerZoneCopy.title)).toBeInTheDocument(); - -// // Check that the description is present -// expect(screen.getByText(dangerZoneCopy.description)).toBeInTheDocument(); - -// // Find the Delete All Data button in the danger zone -// const deleteAccountButton = screen.getByRole('button', { name: deleteModalCopy.buttons.delete }); -// expect(deleteAccountButton).toBeInTheDocument(); -// }); - -// it('opens Delete All Data modal when Delete All Data button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find and click the Delete All Data button in the danger zone (the smaller one) -// const deleteAccountButtons = screen.getAllByRole('button', { name: deleteModalCopy.buttons.delete }); -// const dangerZoneDeleteButton = deleteAccountButtons.find(button => -// button.className.includes('text-xs') -// ); - -// expect(dangerZoneDeleteButton).toBeInTheDocument(); -// await user.click(dangerZoneDeleteButton!); - -// // Check that the Delete All Data modal appears with proper title -// await waitFor(() => { -// expect(screen.getByText(deleteModalCopy.warningTitle)).toBeInTheDocument(); -// expect(screen.getByText(deleteModalCopy.warningDescription)).toBeInTheDocument(); -// }); -// }); - -// it('successfully deletes account when confirmed', async () => { -// // Mock successful DELETE response for account deletion -// server.use( -// http.delete(`${API_BASE_URL}/profile/delete-all`, () => -// HttpResponse.json({}, { status: 200 }) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find and click the Delete All Data button in the danger zone -// const deleteAccountButtons = screen.getAllByRole('button', { name: deleteModalCopy.buttons.delete }); -// const dangerZoneDeleteButton = deleteAccountButtons.find(button => -// button.className.includes('text-xs') -// ); -// await user.click(dangerZoneDeleteButton!); - -// // Wait for modal to appear and proceed to confirmation step -// await waitFor(() => { -// expect(screen.getByText(deleteModalCopy.warningTitle)).toBeInTheDocument(); -// }); - -// // Click Continue button to proceed to confirmation step -// const continueButton = screen.getByRole('button', { name: deleteModalCopy.buttons.continue }); -// await user.click(continueButton); - -// // Wait for confirmation step and type the confirmation keyword -// await waitFor(() => { -// expect(screen.getByText(/confirm deletion/i)).toBeInTheDocument(); -// }); - -// const confirmInput = screen.getByRole('textbox'); -// await user.type(confirmInput, 'DELETE'); - -// // Click the final delete button (the one in the modal) -// const deleteButtons = screen.getAllByRole('button', { name: deleteModalCopy.buttons.delete }); -// const modalDeleteButton = deleteButtons.find(button => -// button.className.includes('flex-1') && button.className.includes('bg-destructive') -// ); -// await user.click(modalDeleteButton!); - -// // Wait for the deletion process to complete -// await waitFor(() => { -// // The component should handle the deletion gracefully -// expect(document.body).toBeInTheDocument(); -// }); -// }); - -// it('handles Delete All Data API error gracefully', async () => { -// // Mock error response for account deletion -// server.use( -// http.delete(`${API_BASE_URL}/profile/delete-all`, () => -// HttpResponse.json( -// { message: 'Failed to Delete All Data' }, -// { status: 500 } -// ) -// ) -// ); - -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find and click the Delete All Data button in the danger zone -// const deleteAccountButtons = screen.getAllByRole('button', { name: deleteModalCopy.buttons.delete }); -// const dangerZoneDeleteButton = deleteAccountButtons.find(button => -// button.className.includes('text-xs') -// ); -// await user.click(dangerZoneDeleteButton!); - -// // Wait for modal and proceed through confirmation -// await waitFor(() => { -// expect(screen.getByText(deleteModalCopy.warningTitle)).toBeInTheDocument(); -// }); - -// // Click Continue button -// const continueButton = screen.getByRole('button', { name: deleteModalCopy.buttons.continue }); -// await user.click(continueButton); - -// // Type confirmation and attempt deletion -// await waitFor(() => { -// expect(screen.getByText(/confirm deletion/i)).toBeInTheDocument(); -// }); - -// const confirmInput = screen.getByRole('textbox'); -// await user.type(confirmInput, 'DELETE'); - -// const deleteButtons = screen.getAllByRole('button', { name: deleteModalCopy.buttons.delete }); -// const modalDeleteButton = deleteButtons.find(button => -// button.className.includes('flex-1') && button.className.includes('bg-destructive') -// ); -// await user.click(modalDeleteButton!); - -// // The error should be handled gracefully -// await waitFor(() => { -// expect(document.body).toBeInTheDocument(); -// }); -// }); - -// it('cancels Delete All Data operation when cancel button is clicked', async () => { -// renderWithProviders(); -// const user = setupUserEvent(); - -// // Wait for profile to load -// await waitFor(() => { -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); - -// // Find and click the Delete All Data button -// const deleteAccountButton = screen.getByRole('button', { name: deleteModalCopy.buttons.delete }); -// await user.click(deleteAccountButton); - -// // Wait for modal to appear -// await waitFor(() => { -// expect(screen.getByText(deleteModalCopy.warningTitle)).toBeInTheDocument(); -// }); - -// // Find and click the cancel button -// const cancelButton = screen.getByRole('button', { name: 'Cancel' }); -// await user.click(cancelButton); - -// // Modal should close - check that warning text is no longer visible -// await waitFor(() => { -// expect(screen.queryByText('Warning')).not.toBeInTheDocument(); -// expect(screen.queryByText('This action cannot be undone')).not.toBeInTheDocument(); -// }); - -// // Profile should still be displayed -// expect(screen.getByText('John Doe')).toBeInTheDocument(); -// }); -// }); -// }); From fbd853492338f27b093048b121440c284a6ef221 Mon Sep 17 00:00:00 2001 From: ykaydogdu Date: Fri, 5 Dec 2025 22:10:46 +0300 Subject: [PATCH 010/206] refactor: move components in new modules --- .../profile/components/AboutSection.test.tsx | 2 +- .../profile/components/ProfileHeader.test.tsx | 2 +- .../components/PublicViewMode.test.tsx | 10 +- .../profile/components/SkillsSection.test.tsx | 2 +- .../components/CreateWorkplaceModal.test.tsx | 12 +- .../components/EmployerWorkplaceCard.test.tsx | 4 +- .../components/JoinWorkplaceModal.test.tsx | 12 +- .../components/NewWorkplaceModal.test.tsx | 2 +- .../components/WorkplaceCard.test.tsx | 4 +- .../src/components/ui/.gitkeep | 0 .../chat}/components/chat/ChatInterface.tsx | 10 +- .../chat}/components/chat/ChatRoomList.tsx | 10 +- .../chat}/components/chat/MessageBubble.tsx | 6 +- .../forum}/components/forum/Comment.tsx | 11 +- .../forum}/components/forum/CommentForm.tsx | 4 +- .../forum}/components/forum/ForumPost.tsx | 6 +- .../components/forum/LikeDislikeButtons.tsx | 2 +- .../forum}/components/forum/ReportDialog.tsx | 6 +- .../components/jobs/CreateJobPostModal.tsx | 28 +- .../jobs}/components/jobs/JobCard.tsx | 262 ++++++++-------- .../jobs}/components/jobs/JobFilters.tsx | 250 ++++++++-------- .../components/jobs/MobileJobFilters.tsx | 166 +++++------ .../components/jobs/NonProfitJobCard.tsx | 10 +- .../components/mentorship/MentorCard.tsx | 16 +- .../components/profile/AboutSection.tsx | 2 +- .../components/profile/ActivityTab.tsx | 2 +- .../components/profile/DeleteAccountModal.tsx | 2 +- .../components/profile/EducationItem.tsx | 2 +- .../components/profile/EducationSection.tsx | 2 +- .../components/profile/ExperienceItem.tsx | 2 +- .../components/profile/ExperienceSection.tsx | 2 +- .../components/profile/InterestsSection.tsx | 2 +- .../profile}/components/profile/PostsTab.tsx | 0 .../components/profile/ProfileEditModals.tsx | 4 +- .../components/profile/ProfileHeader.tsx | 4 +- .../components/profile/SkillsSection.tsx | 2 +- .../components/reviews/ReplyCard.tsx | 12 +- .../components/reviews/ReplyFormDialog.tsx | 14 +- .../components/reviews/ReviewCard.tsx | 16 +- .../components/reviews/ReviewFormDialog.tsx | 22 +- .../components/reviews/ReviewList.tsx | 6 +- .../components/reviews/ReviewStats.tsx | 6 +- .../components/common}/CenteredError.tsx | 4 +- .../components/common}/CenteredLoader.tsx | 0 .../components/common}/ErrorBoundary.tsx | 2 +- .../components/common}/LanguageSwitcher.tsx | 4 +- .../components/common}/ProtectedRoute.tsx | 4 +- .../shared/components/common}/ThemeToggle.tsx | 4 +- .../jobs/CreateJobPostModal.test.tsx | 6 +- .../common}/__tests__/jobs/JobCard.test.tsx | 4 +- .../shared}/components/layout/Footer.tsx | 0 .../shared}/components/layout/Header.tsx | 18 +- .../shared}/components/ui/avatar.tsx | 2 +- .../shared}/components/ui/badge.tsx | 96 +++--- .../shared}/components/ui/button.tsx | 2 +- .../shared}/components/ui/card.tsx | 2 +- .../shared}/components/ui/checkbox.tsx | 60 ++-- .../shared}/components/ui/dialog.tsx | 282 +++++++++--------- .../shared}/components/ui/dropdown-menu.tsx | 2 +- .../shared}/components/ui/input.tsx | 2 +- .../shared}/components/ui/label.tsx | 44 +-- .../components/ui/multi-select-dropdown.tsx | 4 +- .../shared}/components/ui/pagination.tsx | 254 ++++++++-------- .../shared}/components/ui/scroll-area.tsx | 2 +- .../shared}/components/ui/separator.tsx | 52 ++-- .../shared}/components/ui/sheet.tsx | 2 +- .../shared}/components/ui/slider.tsx | 122 ++++---- .../shared}/components/ui/star-rating.tsx | 2 +- .../shared}/components/ui/tabs.tsx | 2 +- .../shared}/components/ui/textarea.tsx | 36 +-- .../components/report/ReportModal.tsx | 8 +- .../report/__tests__/ReportModal.test.tsx | 0 .../workplace/CreateWorkplaceModal.tsx | 20 +- .../workplace/EmployerWorkplaceCard.tsx | 10 +- .../workplace/JoinWorkplaceModal.tsx | 26 +- .../workplace/NewWorkplaceModal.tsx | 4 +- .../components/workplace/WorkplaceCard.tsx | 8 +- .../workplace/WorkplaceSelector.tsx | 10 +- 78 files changed, 1024 insertions(+), 1015 deletions(-) delete mode 100644 apps/jobboard-frontend/src/components/ui/.gitkeep rename apps/jobboard-frontend/src/{ => modules/chat}/components/chat/ChatInterface.tsx (93%) rename apps/jobboard-frontend/src/{ => modules/chat}/components/chat/ChatRoomList.tsx (94%) rename apps/jobboard-frontend/src/{ => modules/chat}/components/chat/MessageBubble.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/forum}/components/forum/Comment.tsx (87%) rename apps/jobboard-frontend/src/{ => modules/forum}/components/forum/CommentForm.tsx (86%) rename apps/jobboard-frontend/src/{ => modules/forum}/components/forum/ForumPost.tsx (93%) rename apps/jobboard-frontend/src/{ => modules/forum}/components/forum/LikeDislikeButtons.tsx (92%) rename apps/jobboard-frontend/src/{ => modules/forum}/components/forum/ReportDialog.tsx (92%) rename apps/jobboard-frontend/src/{ => modules/jobs}/components/jobs/CreateJobPostModal.tsx (93%) rename apps/jobboard-frontend/src/{ => modules/jobs}/components/jobs/JobCard.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/jobs}/components/jobs/JobFilters.tsx (87%) rename apps/jobboard-frontend/src/{ => modules/jobs}/components/jobs/MobileJobFilters.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/jobs}/components/jobs/NonProfitJobCard.tsx (93%) rename apps/jobboard-frontend/src/{ => modules/mentorship}/components/mentorship/MentorCard.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/AboutSection.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/ActivityTab.tsx (99%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/DeleteAccountModal.tsx (99%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/EducationItem.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/EducationSection.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/ExperienceItem.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/ExperienceSection.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/InterestsSection.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/PostsTab.tsx (100%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/ProfileEditModals.tsx (99%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/ProfileHeader.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/profile}/components/profile/SkillsSection.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/resumeReview}/components/reviews/ReplyCard.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/resumeReview}/components/reviews/ReplyFormDialog.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/resumeReview}/components/reviews/ReviewCard.tsx (92%) rename apps/jobboard-frontend/src/{ => modules/resumeReview}/components/reviews/ReviewFormDialog.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/resumeReview}/components/reviews/ReviewList.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/resumeReview}/components/reviews/ReviewStats.tsx (97%) rename apps/jobboard-frontend/src/{components => modules/shared/components/common}/CenteredError.tsx (92%) rename apps/jobboard-frontend/src/{components => modules/shared/components/common}/CenteredLoader.tsx (100%) rename apps/jobboard-frontend/src/{components => modules/shared/components/common}/ErrorBoundary.tsx (95%) rename apps/jobboard-frontend/src/{components => modules/shared/components/common}/LanguageSwitcher.tsx (94%) rename apps/jobboard-frontend/src/{components => modules/shared/components/common}/ProtectedRoute.tsx (91%) rename apps/jobboard-frontend/src/{components => modules/shared/components/common}/ThemeToggle.tsx (83%) rename apps/jobboard-frontend/src/{components => modules/shared/components/common}/__tests__/jobs/CreateJobPostModal.test.tsx (95%) rename apps/jobboard-frontend/src/{components => modules/shared/components/common}/__tests__/jobs/JobCard.test.tsx (99%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/layout/Footer.tsx (100%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/layout/Header.tsx (94%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/avatar.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/badge.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/button.tsx (98%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/card.tsx (98%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/checkbox.tsx (94%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/dialog.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/dropdown-menu.tsx (99%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/input.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/label.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/multi-select-dropdown.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/pagination.tsx (92%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/scroll-area.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/separator.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/sheet.tsx (99%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/slider.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/star-rating.tsx (98%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/tabs.tsx (97%) rename apps/jobboard-frontend/src/{ => modules/shared}/components/ui/textarea.tsx (93%) rename apps/jobboard-frontend/src/{ => modules/workplace}/components/report/ReportModal.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/workplace}/components/report/__tests__/ReportModal.test.tsx (100%) rename apps/jobboard-frontend/src/{ => modules/workplace}/components/workplace/CreateWorkplaceModal.tsx (94%) rename apps/jobboard-frontend/src/{ => modules/workplace}/components/workplace/EmployerWorkplaceCard.tsx (95%) rename apps/jobboard-frontend/src/{ => modules/workplace}/components/workplace/JoinWorkplaceModal.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/workplace}/components/workplace/NewWorkplaceModal.tsx (96%) rename apps/jobboard-frontend/src/{ => modules/workplace}/components/workplace/WorkplaceCard.tsx (91%) rename apps/jobboard-frontend/src/{ => modules/workplace}/components/workplace/WorkplaceSelector.tsx (94%) diff --git a/apps/jobboard-frontend/src/__tests__/profile/components/AboutSection.test.tsx b/apps/jobboard-frontend/src/__tests__/profile/components/AboutSection.test.tsx index d8a56aa2..4a4af702 100644 --- a/apps/jobboard-frontend/src/__tests__/profile/components/AboutSection.test.tsx +++ b/apps/jobboard-frontend/src/__tests__/profile/components/AboutSection.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi } from 'vitest'; -import { AboutSection } from '@/components/profile/AboutSection'; +import { AboutSection } from '@modules/profile/components/profile/AboutSection'; vi.mock('react-i18next', async () => await import('@/test/__mocks__/react-i18next')); diff --git a/apps/jobboard-frontend/src/__tests__/profile/components/ProfileHeader.test.tsx b/apps/jobboard-frontend/src/__tests__/profile/components/ProfileHeader.test.tsx index 45dda7b2..60ce6f9b 100644 --- a/apps/jobboard-frontend/src/__tests__/profile/components/ProfileHeader.test.tsx +++ b/apps/jobboard-frontend/src/__tests__/profile/components/ProfileHeader.test.tsx @@ -2,7 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { vi, describe, it, expect } from 'vitest'; vi.mock('react-i18next', async () => await import('@/test/__mocks__/react-i18next')); -import { ProfileHeader } from '@/components/profile/ProfileHeader'; +import { ProfileHeader } from '@modules/profile/components/profile/ProfileHeader'; const baseProps = { firstName: 'John', diff --git a/apps/jobboard-frontend/src/__tests__/profile/components/PublicViewMode.test.tsx b/apps/jobboard-frontend/src/__tests__/profile/components/PublicViewMode.test.tsx index 04d010f8..f6b98779 100644 --- a/apps/jobboard-frontend/src/__tests__/profile/components/PublicViewMode.test.tsx +++ b/apps/jobboard-frontend/src/__tests__/profile/components/PublicViewMode.test.tsx @@ -1,9 +1,9 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; -import { AboutSection } from '@/components/profile/AboutSection'; -import { ExperienceSection } from '@/components/profile/ExperienceSection'; -import { EducationSection } from '@/components/profile/EducationSection'; -import type { Experience, Education } from '@/types/profile.types'; +import { AboutSection } from '@modules/profile/components/profile/AboutSection'; +import { ExperienceSection } from '@modules/profile/components/profile/ExperienceSection'; +import { EducationSection } from '@modules/profile/components/profile/EducationSection'; +import type { Experience, Education } from '@shared/types/profile.types'; vi.mock('react-i18next', async () => await import('@/test/__mocks__/react-i18next')); @@ -463,4 +463,4 @@ describe('Profile Components - Public View Mode', () => { expect(screen.getByText(/Test School/)).toBeInTheDocument(); }); }); -}); \ No newline at end of file +}); diff --git a/apps/jobboard-frontend/src/__tests__/profile/components/SkillsSection.test.tsx b/apps/jobboard-frontend/src/__tests__/profile/components/SkillsSection.test.tsx index 9a0696db..078ffba3 100644 --- a/apps/jobboard-frontend/src/__tests__/profile/components/SkillsSection.test.tsx +++ b/apps/jobboard-frontend/src/__tests__/profile/components/SkillsSection.test.tsx @@ -1,7 +1,7 @@ import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi } from 'vitest'; -import { SkillsSection } from '@/components/profile/SkillsSection'; +import { SkillsSection } from '@modules/profile/components/profile/SkillsSection'; vi.mock('react-i18next', async () => await import('@/test/__mocks__/react-i18next')); diff --git a/apps/jobboard-frontend/src/__tests__/workplace/components/CreateWorkplaceModal.test.tsx b/apps/jobboard-frontend/src/__tests__/workplace/components/CreateWorkplaceModal.test.tsx index ef1476fa..466dfaf1 100644 --- a/apps/jobboard-frontend/src/__tests__/workplace/components/CreateWorkplaceModal.test.tsx +++ b/apps/jobboard-frontend/src/__tests__/workplace/components/CreateWorkplaceModal.test.tsx @@ -1,20 +1,20 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { CreateWorkplaceModal } from '@/components/workplace/CreateWorkplaceModal'; +import { CreateWorkplaceModal } from '@modules/workplace/components/workplace/CreateWorkplaceModal'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { BrowserRouter } from 'react-router-dom'; -import * as workplaceService from '@/services/workplace.service'; -import type { WorkplaceDetailResponse } from '@/types/workplace.types'; -import type { EthicalTag } from '@/types/job'; +import * as workplaceService from '@modules/workplace/services/workplace.service'; +import type { WorkplaceDetailResponse } from '@shared/types/workplace.types'; +import type { EthicalTag } from '@shared/types/job'; vi.mock('react-i18next', async () => await import('@/test/__mocks__/react-i18next')); // Mock the service -vi.mock('@/services/workplace.service', () => ({ +vi.mock('@modules/workplace/services/workplace.service', () => ({ createWorkplace: vi.fn(), })); // Mock MultiSelectDropdown -vi.mock('@/components/ui/multi-select-dropdown', () => ({ +vi.mock('@shared/components/ui/multi-select-dropdown', () => ({ MultiSelectDropdown: ({ onTagsChange }: { selectedTags: EthicalTag[]; onTagsChange: (tags: EthicalTag[]) => void; placeholder?: string }) => ( diff --git a/apps/jobboard-frontend/src/components/forum/CommentForm.tsx b/apps/jobboard-frontend/src/modules/forum/components/forum/CommentForm.tsx similarity index 86% rename from apps/jobboard-frontend/src/components/forum/CommentForm.tsx rename to apps/jobboard-frontend/src/modules/forum/components/forum/CommentForm.tsx index 461f08b3..c2489d1d 100644 --- a/apps/jobboard-frontend/src/components/forum/CommentForm.tsx +++ b/apps/jobboard-frontend/src/modules/forum/components/forum/CommentForm.tsx @@ -1,5 +1,5 @@ -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Button } from "@shared/components/ui/button"; +import { Input } from "@shared/components/ui/input"; import { useState } from "react"; interface CommentFormProps { diff --git a/apps/jobboard-frontend/src/components/forum/ForumPost.tsx b/apps/jobboard-frontend/src/modules/forum/components/forum/ForumPost.tsx similarity index 93% rename from apps/jobboard-frontend/src/components/forum/ForumPost.tsx rename to apps/jobboard-frontend/src/modules/forum/components/forum/ForumPost.tsx index 0930912c..380af4c5 100644 --- a/apps/jobboard-frontend/src/components/forum/ForumPost.tsx +++ b/apps/jobboard-frontend/src/modules/forum/components/forum/ForumPost.tsx @@ -1,6 +1,6 @@ -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; +import { Button } from "@shared/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@shared/components/ui/card"; +import { Badge } from "@shared/components/ui/badge"; import Comment from "./Comment"; import CommentForm from "./CommentForm"; import LikeDislikeButtons from "./LikeDislikeButtons"; diff --git a/apps/jobboard-frontend/src/components/forum/LikeDislikeButtons.tsx b/apps/jobboard-frontend/src/modules/forum/components/forum/LikeDislikeButtons.tsx similarity index 92% rename from apps/jobboard-frontend/src/components/forum/LikeDislikeButtons.tsx rename to apps/jobboard-frontend/src/modules/forum/components/forum/LikeDislikeButtons.tsx index f1f6aa8f..fefafe81 100644 --- a/apps/jobboard-frontend/src/components/forum/LikeDislikeButtons.tsx +++ b/apps/jobboard-frontend/src/modules/forum/components/forum/LikeDislikeButtons.tsx @@ -1,4 +1,4 @@ -import { Button } from "@/components/ui/button"; +import { Button } from "@shared/components/ui/button"; import { ThumbsUp, ThumbsDown } from "lucide-react"; interface LikeDislikeButtonsProps { diff --git a/apps/jobboard-frontend/src/components/forum/ReportDialog.tsx b/apps/jobboard-frontend/src/modules/forum/components/forum/ReportDialog.tsx similarity index 92% rename from apps/jobboard-frontend/src/components/forum/ReportDialog.tsx rename to apps/jobboard-frontend/src/modules/forum/components/forum/ReportDialog.tsx index 0a69f8dc..bd6bacb9 100644 --- a/apps/jobboard-frontend/src/components/forum/ReportDialog.tsx +++ b/apps/jobboard-frontend/src/modules/forum/components/forum/ReportDialog.tsx @@ -6,9 +6,9 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" +} from "@shared/components/ui/dialog" +import { Button } from "@shared/components/ui/button" +import { Textarea } from "@shared/components/ui/textarea" import { ShieldAlert } from "lucide-react" import { useState } from "react" diff --git a/apps/jobboard-frontend/src/components/jobs/CreateJobPostModal.tsx b/apps/jobboard-frontend/src/modules/jobs/components/jobs/CreateJobPostModal.tsx similarity index 93% rename from apps/jobboard-frontend/src/components/jobs/CreateJobPostModal.tsx rename to apps/jobboard-frontend/src/modules/jobs/components/jobs/CreateJobPostModal.tsx index 61d645de..e4e817c4 100644 --- a/apps/jobboard-frontend/src/components/jobs/CreateJobPostModal.tsx +++ b/apps/jobboard-frontend/src/modules/jobs/components/jobs/CreateJobPostModal.tsx @@ -2,20 +2,20 @@ import { useEffect, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; import { Building2, Plus, UserPlus } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Card } from '@/components/ui/card'; -import WorkplaceSelector from '@/components/workplace/WorkplaceSelector'; -import { CreateWorkplaceModal } from '@/components/workplace/CreateWorkplaceModal'; -import { JoinWorkplaceModal } from '@/components/workplace/JoinWorkplaceModal'; -import CenteredLoader from '@/components/CenteredLoader'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { createJob } from '@/services/jobs.service'; -import { getMyWorkplaces } from '@/services/employer.service'; -import type { CreateJobPostRequest } from '@/types/api.types'; -import type { EmployerWorkplaceBrief } from '@/types/workplace.types'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Label } from '@shared/components/ui/label'; +import { Checkbox } from '@shared/components/ui/checkbox'; +import { Card } from '@shared/components/ui/card'; +import WorkplaceSelector from '@modules/workplace/components/workplace/WorkplaceSelector'; +import { CreateWorkplaceModal } from '@modules/workplace/components/workplace/CreateWorkplaceModal'; +import { JoinWorkplaceModal } from '@modules/workplace/components/workplace/JoinWorkplaceModal'; +import CenteredLoader from '@shared/components/common/CenteredLoader'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@shared/components/ui/dialog'; +import { createJob } from '@modules/jobs/services/jobs.service'; +import { getMyWorkplaces } from '@modules/employer/services/employer.service'; +import type { CreateJobPostRequest } from '@shared/types/api.types'; +import type { EmployerWorkplaceBrief } from '@shared/types/workplace.types'; type JobPostFormData = { title: string; diff --git a/apps/jobboard-frontend/src/components/jobs/JobCard.tsx b/apps/jobboard-frontend/src/modules/jobs/components/jobs/JobCard.tsx similarity index 91% rename from apps/jobboard-frontend/src/components/jobs/JobCard.tsx rename to apps/jobboard-frontend/src/modules/jobs/components/jobs/JobCard.tsx index 7b659c9b..84663c31 100644 --- a/apps/jobboard-frontend/src/components/jobs/JobCard.tsx +++ b/apps/jobboard-frontend/src/modules/jobs/components/jobs/JobCard.tsx @@ -1,131 +1,131 @@ -import { useTranslation } from 'react-i18next'; -import { Briefcase, DollarSign, MapPin, Accessibility } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { Badge } from '@/components/ui/badge'; -import { Card } from '@/components/ui/card'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { type Job, type JobType } from '@/types/job'; -import { TAG_TO_KEY_MAP } from '@/constants/ethical-tags'; - -type JobCardProps = { - job: Job; -}; - -function formatSalary(value: number) { - return `$${value}k`; -} - -const jobTypeLabelKeyMap: Record = { - 'Full-time': 'jobFilters.jobTypeOptions.fullTime', - 'Part-time': 'jobFilters.jobTypeOptions.partTime', - 'Contract': 'jobFilters.jobTypeOptions.contract', -}; - -export function JobCard({ job }: JobCardProps) { - const navigate = useNavigate(); - const { t } = useTranslation('common'); - - const handleCardClick = () => { - // Prevent navigation if clicking on workplace link (if we add one inside) - // For now, the whole card navigates to job detail - navigate(`/jobs/${job.id}`); - }; - - const handleWorkplaceClick = (e: React.MouseEvent) => { - e.stopPropagation(); - navigate(`/workplace/${job.workplace.id}`); - }; - - const ethicalTagLabels = job.workplace.ethicalTags.map((tag) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const key = (TAG_TO_KEY_MAP as any)[tag]; - return t(`ethicalTags.tags.${key ?? tag}`, tag); - }); - const jobTypes = job.type.map((type) => t(jobTypeLabelKeyMap[type] ?? type)); - const location = - job.location.toLowerCase() === 'remote' ? t('jobCard.remote') : job.location; - - return ( - -
-
- - - - {job.workplace.companyName - .split(' ') - .map((part) => part[0]) - .join('') - .slice(0, 3)} - - -
-
-
- {job.inclusiveOpportunity && ( - - - {t('jobCard.inclusiveOpportunity')} - - )} - {ethicalTagLabels.slice(0, 3).map((tag, idx) => ( - - {tag} - - ))} - {ethicalTagLabels.length > 3 && ( - - +{ethicalTagLabels.length - 3} - - )} -
- - {/* Job Title and Company */} -
-
- {job.title} -
-
- {job.workplace.companyName} -
-
- - {/* Job Types, Salary, Location */} -
- - - {jobTypes.join(' / ')} - - - - {formatSalary(job.minSalary)} - {formatSalary(job.maxSalary)} - - - - {location} - -
-
-
-
- ); -} +import { useTranslation } from 'react-i18next'; +import { Briefcase, DollarSign, MapPin, Accessibility } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { Badge } from '@shared/components/ui/badge'; +import { Card } from '@shared/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; +import { type Job, type JobType } from '@shared/types/job'; +import { TAG_TO_KEY_MAP } from '@shared/constants/ethical-tags'; + +type JobCardProps = { + job: Job; +}; + +function formatSalary(value: number) { + return `$${value}k`; +} + +const jobTypeLabelKeyMap: Record = { + 'Full-time': 'jobFilters.jobTypeOptions.fullTime', + 'Part-time': 'jobFilters.jobTypeOptions.partTime', + 'Contract': 'jobFilters.jobTypeOptions.contract', +}; + +export function JobCard({ job }: JobCardProps) { + const navigate = useNavigate(); + const { t } = useTranslation('common'); + + const handleCardClick = () => { + // Prevent navigation if clicking on workplace link (if we add one inside) + // For now, the whole card navigates to job detail + navigate(`/jobs/${job.id}`); + }; + + const handleWorkplaceClick = (e: React.MouseEvent) => { + e.stopPropagation(); + navigate(`/workplace/${job.workplace.id}`); + }; + + const ethicalTagLabels = job.workplace.ethicalTags.map((tag) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const key = (TAG_TO_KEY_MAP as any)[tag]; + return t(`ethicalTags.tags.${key ?? tag}`, tag); + }); + const jobTypes = job.type.map((type) => t(jobTypeLabelKeyMap[type] ?? type)); + const location = + job.location.toLowerCase() === 'remote' ? t('jobCard.remote') : job.location; + + return ( + +
+
+ + + + {job.workplace.companyName + .split(' ') + .map((part) => part[0]) + .join('') + .slice(0, 3)} + + +
+
+
+ {job.inclusiveOpportunity && ( + + + {t('jobCard.inclusiveOpportunity')} + + )} + {ethicalTagLabels.slice(0, 3).map((tag, idx) => ( + + {tag} + + ))} + {ethicalTagLabels.length > 3 && ( + + +{ethicalTagLabels.length - 3} + + )} +
+ + {/* Job Title and Company */} +
+
+ {job.title} +
+
+ {job.workplace.companyName} +
+
+ + {/* Job Types, Salary, Location */} +
+ + + {jobTypes.join(' / ')} + + + + {formatSalary(job.minSalary)} - {formatSalary(job.maxSalary)} + + + + {location} + +
+
+
+
+ ); +} diff --git a/apps/jobboard-frontend/src/components/jobs/JobFilters.tsx b/apps/jobboard-frontend/src/modules/jobs/components/jobs/JobFilters.tsx similarity index 87% rename from apps/jobboard-frontend/src/components/jobs/JobFilters.tsx rename to apps/jobboard-frontend/src/modules/jobs/components/jobs/JobFilters.tsx index bf170553..383ae549 100644 --- a/apps/jobboard-frontend/src/components/jobs/JobFilters.tsx +++ b/apps/jobboard-frontend/src/modules/jobs/components/jobs/JobFilters.tsx @@ -1,125 +1,125 @@ -import { useTranslation } from 'react-i18next'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; -import { Slider } from '@/components/ui/slider'; -import { MultiSelectDropdown } from '@/components/ui/multi-select-dropdown'; -import { cn } from '@/lib/utils'; -import { useFilters } from '@/hooks/useFilters'; - -const BASE_SALARY_RANGE: [number, number] = [40, 120]; - -type JobFiltersProps = { - className?: string; -}; - -export function JobFilters({ - className, -}: JobFiltersProps) { - const { t } = useTranslation('common'); - const { - selectedEthicalTags, - salaryRange, - companyNameFilter, - isRemoteOnly, - isDisabilityInclusive, - setEthicalTags, - setSalaryRange, - setCompanyName, - setIsRemote, - setIsDisabilityInclusive, - } = useFilters(); - const filtersContent = ( -
-
-

{t('jobFilters.ethicalTags')}

- -
- - - -
-
-

{t('jobFilters.salary')}

- - {t('jobFilters.salaryRangeValue', { min: salaryRange[0], max: salaryRange[1] })} - -
- { - if (Array.isArray(value) && value.length === 2) { - setSalaryRange([value[0], value[1]]); - } - }} - min={BASE_SALARY_RANGE[0]} - max={BASE_SALARY_RANGE[1]} - step={5} - className="w-full bg-green-500" - aria-label={t('jobFilters.salary')} - /> -
- {t('jobFilters.salaryEndpoint', { value: BASE_SALARY_RANGE[0] })} - {t('jobFilters.salaryEndpoint', { value: BASE_SALARY_RANGE[1] })} -
-
- - - -
-

{t('jobFilters.workArrangement')}

-
-
- setIsRemote(checked === true)} - aria-label={t('jobFilters.remoteOnly')} - /> - -
-
-
- - - -
-

{t('jobFilters.inclusivity')}

-
-
- setIsDisabilityInclusive(checked === true)} - aria-label={t('jobFilters.disabilityInclusive')} - /> - -
-
-
- - - -
-

{t('jobFilters.companyName')}

- setCompanyName(event.target.value)} - placeholder={t('jobFilters.companyNamePlaceholder')} - aria-label={t('jobFilters.companyName')} - /> -
-
- ); - - return filtersContent; -} +import { useTranslation } from 'react-i18next'; +import { Checkbox } from '@shared/components/ui/checkbox'; +import { Input } from '@shared/components/ui/input'; +import { Label } from '@shared/components/ui/label'; +import { Separator } from '@shared/components/ui/separator'; +import { Slider } from '@shared/components/ui/slider'; +import { MultiSelectDropdown } from '@shared/components/ui/multi-select-dropdown'; +import { cn } from '@shared/lib/utils'; +import { useFilters } from '@shared/hooks/useFilters'; + +const BASE_SALARY_RANGE: [number, number] = [40, 120]; + +type JobFiltersProps = { + className?: string; +}; + +export function JobFilters({ + className, +}: JobFiltersProps) { + const { t } = useTranslation('common'); + const { + selectedEthicalTags, + salaryRange, + companyNameFilter, + isRemoteOnly, + isDisabilityInclusive, + setEthicalTags, + setSalaryRange, + setCompanyName, + setIsRemote, + setIsDisabilityInclusive, + } = useFilters(); + const filtersContent = ( +
+
+

{t('jobFilters.ethicalTags')}

+ +
+ + + +
+
+

{t('jobFilters.salary')}

+ + {t('jobFilters.salaryRangeValue', { min: salaryRange[0], max: salaryRange[1] })} + +
+ { + if (Array.isArray(value) && value.length === 2) { + setSalaryRange([value[0], value[1]]); + } + }} + min={BASE_SALARY_RANGE[0]} + max={BASE_SALARY_RANGE[1]} + step={5} + className="w-full bg-green-500" + aria-label={t('jobFilters.salary')} + /> +
+ {t('jobFilters.salaryEndpoint', { value: BASE_SALARY_RANGE[0] })} + {t('jobFilters.salaryEndpoint', { value: BASE_SALARY_RANGE[1] })} +
+
+ + + +
+

{t('jobFilters.workArrangement')}

+
+
+ setIsRemote(checked === true)} + aria-label={t('jobFilters.remoteOnly')} + /> + +
+
+
+ + + +
+

{t('jobFilters.inclusivity')}

+
+
+ setIsDisabilityInclusive(checked === true)} + aria-label={t('jobFilters.disabilityInclusive')} + /> + +
+
+
+ + + +
+

{t('jobFilters.companyName')}

+ setCompanyName(event.target.value)} + placeholder={t('jobFilters.companyNamePlaceholder')} + aria-label={t('jobFilters.companyName')} + /> +
+
+ ); + + return filtersContent; +} diff --git a/apps/jobboard-frontend/src/components/jobs/MobileJobFilters.tsx b/apps/jobboard-frontend/src/modules/jobs/components/jobs/MobileJobFilters.tsx similarity index 91% rename from apps/jobboard-frontend/src/components/jobs/MobileJobFilters.tsx rename to apps/jobboard-frontend/src/modules/jobs/components/jobs/MobileJobFilters.tsx index 0ebcbf24..58f49af2 100644 --- a/apps/jobboard-frontend/src/components/jobs/MobileJobFilters.tsx +++ b/apps/jobboard-frontend/src/modules/jobs/components/jobs/MobileJobFilters.tsx @@ -1,83 +1,83 @@ -import React from 'react'; -import { Filter as FilterIcon } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/button'; -import { - Sheet, - SheetContent, - SheetFooter, - SheetHeader, - SheetTitle, - SheetTrigger, -} from '@/components/ui/sheet'; -import { useFilters } from '@/hooks/useFilters'; - -type MobileJobFiltersProps = { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - onResetFilters?: () => void; - filtersContent: React.ReactNode; -}; - -export function MobileJobFilters({ - isOpen, - onOpenChange, - onResetFilters, - filtersContent, -}: MobileJobFiltersProps) { - const { resetFilters } = useFilters(); - const { t, i18n } = useTranslation('common'); - const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; - const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; - - return ( - - - - - - - - {t('mobileFilters.title')} - - -
- {filtersContent} -
- -
- - -
-
-
-
- ); -} +import React from 'react'; +import { Filter as FilterIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@shared/components/ui/button'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from '@shared/components/ui/sheet'; +import { useFilters } from '@shared/hooks/useFilters'; + +type MobileJobFiltersProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onResetFilters?: () => void; + filtersContent: React.ReactNode; +}; + +export function MobileJobFilters({ + isOpen, + onOpenChange, + onResetFilters, + filtersContent, +}: MobileJobFiltersProps) { + const { resetFilters } = useFilters(); + const { t, i18n } = useTranslation('common'); + const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language; + const isRtl = i18n.dir(resolvedLanguage) === 'rtl'; + + return ( + + + + + + + + {t('mobileFilters.title')} + + +
+ {filtersContent} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/jobboard-frontend/src/components/jobs/NonProfitJobCard.tsx b/apps/jobboard-frontend/src/modules/jobs/components/jobs/NonProfitJobCard.tsx similarity index 93% rename from apps/jobboard-frontend/src/components/jobs/NonProfitJobCard.tsx rename to apps/jobboard-frontend/src/modules/jobs/components/jobs/NonProfitJobCard.tsx index 8cd5dec1..86b24a93 100644 --- a/apps/jobboard-frontend/src/components/jobs/NonProfitJobCard.tsx +++ b/apps/jobboard-frontend/src/modules/jobs/components/jobs/NonProfitJobCard.tsx @@ -1,11 +1,11 @@ import { useTranslation } from 'react-i18next'; import { MapPin, Heart, Accessibility } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; -import { Badge } from '@/components/ui/badge'; -import { Card } from '@/components/ui/card'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { type Job } from '@/types/job'; -import { TAG_TO_KEY_MAP } from '@/constants/ethical-tags'; +import { Badge } from '@shared/components/ui/badge'; +import { Card } from '@shared/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; +import { type Job } from '@shared/types/job'; +import { TAG_TO_KEY_MAP } from '@shared/constants/ethical-tags'; type NonProfitJobCardProps = { job: Job; diff --git a/apps/jobboard-frontend/src/components/mentorship/MentorCard.tsx b/apps/jobboard-frontend/src/modules/mentorship/components/mentorship/MentorCard.tsx similarity index 91% rename from apps/jobboard-frontend/src/components/mentorship/MentorCard.tsx rename to apps/jobboard-frontend/src/modules/mentorship/components/mentorship/MentorCard.tsx index 26e28319..c166982c 100644 --- a/apps/jobboard-frontend/src/components/mentorship/MentorCard.tsx +++ b/apps/jobboard-frontend/src/modules/mentorship/components/mentorship/MentorCard.tsx @@ -1,14 +1,14 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@shared/components/ui/card"; +import { Button } from "@shared/components/ui/button"; import { Star, Users } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@shared/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@shared/components/ui/avatar"; import { Link, useNavigate } from "react-router-dom"; -import type { Mentor } from "@/types/mentor"; -import { createMentorshipRequest } from "@/services/mentorship.service"; -import { useAuth } from "@/contexts/AuthContext"; +import type { Mentor } from "@shared/types/mentor"; +import { createMentorshipRequest } from "@modules/mentorship/services/mentorship.service"; +import { useAuth } from "@shared/contexts/AuthContext"; import { toast } from "react-toastify"; interface MentorCardProps { @@ -31,7 +31,7 @@ const MentorCard = ({ mentor, hasRequested = false }: MentorCardProps) => { // Check if user already has a pending/active request with this mentor try { - const { getMenteeMentorships } = await import('@/services/mentorship.service'); + const { getMenteeMentorships } = await import('@modules/mentorship/services/mentorship.service'); const mentorships = await getMenteeMentorships(user.id); const mentorIdNum = parseInt(mentor.id, 10); const existingRequest = mentorships.find( diff --git a/apps/jobboard-frontend/src/components/profile/AboutSection.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/AboutSection.tsx similarity index 96% rename from apps/jobboard-frontend/src/components/profile/AboutSection.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/AboutSection.tsx index 6c4c5d9a..92d57e57 100644 --- a/apps/jobboard-frontend/src/components/profile/AboutSection.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/AboutSection.tsx @@ -1,5 +1,5 @@ import { Pencil, Plus } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { useTranslation } from 'react-i18next'; interface AboutSectionProps { diff --git a/apps/jobboard-frontend/src/components/profile/ActivityTab.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/ActivityTab.tsx similarity index 99% rename from apps/jobboard-frontend/src/components/profile/ActivityTab.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/ActivityTab.tsx index 07a2117a..5fe1f0a5 100644 --- a/apps/jobboard-frontend/src/components/profile/ActivityTab.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/ActivityTab.tsx @@ -20,4 +20,4 @@ export function ActivityTab({ activities }: ActivityTabProps) { ))} ); -} \ No newline at end of file +} diff --git a/apps/jobboard-frontend/src/components/profile/DeleteAccountModal.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/DeleteAccountModal.tsx similarity index 99% rename from apps/jobboard-frontend/src/components/profile/DeleteAccountModal.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/DeleteAccountModal.tsx index 1b3b30d5..d118d256 100644 --- a/apps/jobboard-frontend/src/components/profile/DeleteAccountModal.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/DeleteAccountModal.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { Trash2, AlertTriangle, X } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { useTranslation, Trans } from 'react-i18next'; interface DeleteAccountModalProps { diff --git a/apps/jobboard-frontend/src/components/profile/EducationItem.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/EducationItem.tsx similarity index 97% rename from apps/jobboard-frontend/src/components/profile/EducationItem.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/EducationItem.tsx index a9305599..18f06452 100644 --- a/apps/jobboard-frontend/src/components/profile/EducationItem.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/EducationItem.tsx @@ -1,5 +1,5 @@ import { Pencil, GraduationCap, Trash2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { useTranslation } from 'react-i18next'; interface Education { diff --git a/apps/jobboard-frontend/src/components/profile/EducationSection.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/EducationSection.tsx similarity index 97% rename from apps/jobboard-frontend/src/components/profile/EducationSection.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/EducationSection.tsx index 9e631acf..8709c4d6 100644 --- a/apps/jobboard-frontend/src/components/profile/EducationSection.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/EducationSection.tsx @@ -1,5 +1,5 @@ import { Plus } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { EducationItem } from './EducationItem'; import { useTranslation } from 'react-i18next'; diff --git a/apps/jobboard-frontend/src/components/profile/ExperienceItem.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/ExperienceItem.tsx similarity index 97% rename from apps/jobboard-frontend/src/components/profile/ExperienceItem.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/ExperienceItem.tsx index 0a45ff95..6ade47d1 100644 --- a/apps/jobboard-frontend/src/components/profile/ExperienceItem.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/ExperienceItem.tsx @@ -1,5 +1,5 @@ import { Pencil, Briefcase, Trash2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { useTranslation } from 'react-i18next'; interface Experience { diff --git a/apps/jobboard-frontend/src/components/profile/ExperienceSection.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/ExperienceSection.tsx similarity index 97% rename from apps/jobboard-frontend/src/components/profile/ExperienceSection.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/ExperienceSection.tsx index 453cf372..ebad05ed 100644 --- a/apps/jobboard-frontend/src/components/profile/ExperienceSection.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/ExperienceSection.tsx @@ -1,5 +1,5 @@ import { Plus } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { ExperienceItem } from './ExperienceItem'; import { useTranslation } from 'react-i18next'; diff --git a/apps/jobboard-frontend/src/components/profile/InterestsSection.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/InterestsSection.tsx similarity index 97% rename from apps/jobboard-frontend/src/components/profile/InterestsSection.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/InterestsSection.tsx index f885e258..963d42b4 100644 --- a/apps/jobboard-frontend/src/components/profile/InterestsSection.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/InterestsSection.tsx @@ -1,5 +1,5 @@ import { Plus, Pencil } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { useTranslation } from 'react-i18next'; interface Interest { diff --git a/apps/jobboard-frontend/src/components/profile/PostsTab.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/PostsTab.tsx similarity index 100% rename from apps/jobboard-frontend/src/components/profile/PostsTab.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/PostsTab.tsx diff --git a/apps/jobboard-frontend/src/components/profile/ProfileEditModals.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/ProfileEditModals.tsx similarity index 99% rename from apps/jobboard-frontend/src/components/profile/ProfileEditModals.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/ProfileEditModals.tsx index e6184a84..879e8645 100644 --- a/apps/jobboard-frontend/src/components/profile/ProfileEditModals.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/ProfileEditModals.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { X, Briefcase, GraduationCap, Award, Heart, Upload, Trash2, Camera } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@shared/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; import { useTranslation } from 'react-i18next'; // Types diff --git a/apps/jobboard-frontend/src/components/profile/ProfileHeader.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/ProfileHeader.tsx similarity index 95% rename from apps/jobboard-frontend/src/components/profile/ProfileHeader.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/ProfileHeader.tsx index c39538a3..d368c91d 100644 --- a/apps/jobboard-frontend/src/components/profile/ProfileHeader.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/ProfileHeader.tsx @@ -1,6 +1,6 @@ import { Pencil } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@shared/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@shared/components/ui/avatar'; import { useTranslation } from 'react-i18next'; interface Experience { diff --git a/apps/jobboard-frontend/src/components/profile/SkillsSection.tsx b/apps/jobboard-frontend/src/modules/profile/components/profile/SkillsSection.tsx similarity index 97% rename from apps/jobboard-frontend/src/components/profile/SkillsSection.tsx rename to apps/jobboard-frontend/src/modules/profile/components/profile/SkillsSection.tsx index 34418760..cdd23cc4 100644 --- a/apps/jobboard-frontend/src/components/profile/SkillsSection.tsx +++ b/apps/jobboard-frontend/src/modules/profile/components/profile/SkillsSection.tsx @@ -1,5 +1,5 @@ import { Plus, Pencil } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { useTranslation } from 'react-i18next'; interface Skill { diff --git a/apps/jobboard-frontend/src/components/reviews/ReplyCard.tsx b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReplyCard.tsx similarity index 91% rename from apps/jobboard-frontend/src/components/reviews/ReplyCard.tsx rename to apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReplyCard.tsx index ecf61beb..8b7261a2 100644 --- a/apps/jobboard-frontend/src/components/reviews/ReplyCard.tsx +++ b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReplyCard.tsx @@ -7,18 +7,18 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { formatDistanceToNow } from 'date-fns'; import { Building2, MoreVertical, Pencil, Trash2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Card, CardContent } from '@/components/ui/card'; +} from '@shared/components/ui/dropdown-menu'; +import { Card, CardContent } from '@shared/components/ui/card'; import { ReplyFormDialog } from './ReplyFormDialog'; -import { deleteReply } from '@/services/reviews.service'; -import type { ReplyResponse } from '@/types/workplace.types'; -import { useAuth } from '@/contexts/AuthContext'; +import { deleteReply } from '@modules/resumeReview/services/reviews.service'; +import type { ReplyResponse } from '@shared/types/workplace.types'; +import { useAuth } from '@shared/contexts/AuthContext'; interface ReplyCardProps { workplaceId: number; diff --git a/apps/jobboard-frontend/src/components/reviews/ReplyFormDialog.tsx b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReplyFormDialog.tsx similarity index 91% rename from apps/jobboard-frontend/src/components/reviews/ReplyFormDialog.tsx rename to apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReplyFormDialog.tsx index 50eed231..3a581bc1 100644 --- a/apps/jobboard-frontend/src/components/reviews/ReplyFormDialog.tsx +++ b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReplyFormDialog.tsx @@ -15,13 +15,13 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; -import { Label } from '@/components/ui/label'; -import { createReply, updateReply } from '@/services/reviews.service'; -import type { ReplyResponse } from '@/types/workplace.types'; -import { getErrorMessage } from '@/utils/error-handler'; +} from '@shared/components/ui/dialog'; +import { Button } from '@shared/components/ui/button'; +import { Textarea } from '@shared/components/ui/textarea'; +import { Label } from '@shared/components/ui/label'; +import { createReply, updateReply } from '@modules/resumeReview/services/reviews.service'; +import type { ReplyResponse } from '@shared/types/workplace.types'; +import { getErrorMessage } from '@shared/utils/error-handler'; const replySchema = z.object({ content: z diff --git a/apps/jobboard-frontend/src/components/reviews/ReviewCard.tsx b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewCard.tsx similarity index 92% rename from apps/jobboard-frontend/src/components/reviews/ReviewCard.tsx rename to apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewCard.tsx index 0bd2c022..6d301243 100644 --- a/apps/jobboard-frontend/src/components/reviews/ReviewCard.tsx +++ b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewCard.tsx @@ -1,17 +1,17 @@ -import { StarRating } from '@/components/ui/star-rating'; -import { Card } from '@/components/ui/card'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Button } from '@/components/ui/button'; +import { StarRating } from '@shared/components/ui/star-rating'; +import { Card } from '@shared/components/ui/card'; +import { Avatar, AvatarFallback } from '@shared/components/ui/avatar'; +import { Button } from '@shared/components/ui/button'; import { ThumbsUp, Flag, MessageSquare } from 'lucide-react'; import { ReplyCard } from './ReplyCard'; import { ReplyFormDialog } from './ReplyFormDialog'; -import type { ReviewResponse } from '@/types/workplace.types'; +import type { ReviewResponse } from '@shared/types/workplace.types'; import { useTranslation } from 'react-i18next'; import { useState } from 'react'; import { formatDistanceToNow } from 'date-fns'; -import { useReportModal } from '@/hooks/useReportModal'; -import { reportWorkplaceReview } from '@/services/workplace-report.service'; -import { useReviewHelpful } from '@/hooks/useReviewHelpful'; +import { useReportModal } from '@shared/hooks/useReportModal'; +import { reportWorkplaceReview } from '@modules/workplace/services/workplace-report.service'; +import { useReviewHelpful } from '@shared/hooks/useReviewHelpful'; interface ReviewCardProps { workplaceId: number; diff --git a/apps/jobboard-frontend/src/components/reviews/ReviewFormDialog.tsx b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewFormDialog.tsx similarity index 91% rename from apps/jobboard-frontend/src/components/reviews/ReviewFormDialog.tsx rename to apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewFormDialog.tsx index f5e14069..340e6555 100644 --- a/apps/jobboard-frontend/src/components/reviews/ReviewFormDialog.tsx +++ b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewFormDialog.tsx @@ -7,18 +7,18 @@ import { DialogHeader, DialogTitle, DialogTrigger, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Checkbox } from '@/components/ui/checkbox'; -import { StarRating } from '@/components/ui/star-rating'; +} from '@shared/components/ui/dialog'; +import { Button } from '@shared/components/ui/button'; +import { Label } from '@shared/components/ui/label'; +import { Input } from '@shared/components/ui/input'; +import { Textarea } from '@shared/components/ui/textarea'; +import { Checkbox } from '@shared/components/ui/checkbox'; +import { StarRating } from '@shared/components/ui/star-rating'; import { useTranslation } from 'react-i18next'; -import { createReview } from '@/services/reviews.service'; -import type { ReviewCreateRequest } from '@/types/workplace.types'; -import { getErrorMessage } from '@/utils/error-handler'; -import { TAG_TO_KEY_MAP } from '@/constants/ethical-tags'; +import { createReview } from '@modules/resumeReview/services/reviews.service'; +import type { ReviewCreateRequest } from '@shared/types/workplace.types'; +import { getErrorMessage } from '@shared/utils/error-handler'; +import { TAG_TO_KEY_MAP } from '@shared/constants/ethical-tags'; interface ReviewFormDialogProps { workplaceId: number; diff --git a/apps/jobboard-frontend/src/components/reviews/ReviewList.tsx b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewList.tsx similarity index 96% rename from apps/jobboard-frontend/src/components/reviews/ReviewList.tsx rename to apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewList.tsx index 8e35b333..721750e0 100644 --- a/apps/jobboard-frontend/src/components/reviews/ReviewList.tsx +++ b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewList.tsx @@ -7,11 +7,11 @@ import { PaginationLink, PaginationNext, PaginationPrevious, -} from '@/components/ui/pagination'; -import type { ReviewResponse, ReviewListParams } from '@/types/workplace.types'; +} from '@shared/components/ui/pagination'; +import type { ReviewResponse, ReviewListParams } from '@shared/types/workplace.types'; import { useTranslation } from 'react-i18next'; import { useState, useEffect } from 'react'; -import { getWorkplaceReviews } from '@/services/reviews.service'; +import { getWorkplaceReviews } from '@modules/resumeReview/services/reviews.service'; import type { ReactNode } from 'react'; interface ReviewListProps { diff --git a/apps/jobboard-frontend/src/components/reviews/ReviewStats.tsx b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewStats.tsx similarity index 97% rename from apps/jobboard-frontend/src/components/reviews/ReviewStats.tsx rename to apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewStats.tsx index 2bc9b6d5..f8c4295d 100644 --- a/apps/jobboard-frontend/src/components/reviews/ReviewStats.tsx +++ b/apps/jobboard-frontend/src/modules/resumeReview/components/reviews/ReviewStats.tsx @@ -1,7 +1,7 @@ -import { StarRating } from '@/components/ui/star-rating'; -import { Card } from '@/components/ui/card'; +import { StarRating } from '@shared/components/ui/star-rating'; +import { Card } from '@shared/components/ui/card'; import { useTranslation } from 'react-i18next'; -import type { ReviewResponse } from '@/types/workplace.types'; +import type { ReviewResponse } from '@shared/types/workplace.types'; interface ReviewStatsProps { overallAvg: number | null | undefined; diff --git a/apps/jobboard-frontend/src/components/CenteredError.tsx b/apps/jobboard-frontend/src/modules/shared/components/common/CenteredError.tsx similarity index 92% rename from apps/jobboard-frontend/src/components/CenteredError.tsx rename to apps/jobboard-frontend/src/modules/shared/components/common/CenteredError.tsx index 53d2c611..de07ad4d 100644 --- a/apps/jobboard-frontend/src/components/CenteredError.tsx +++ b/apps/jobboard-frontend/src/modules/shared/components/common/CenteredError.tsx @@ -1,6 +1,6 @@ import { AlertCircle, RefreshCw } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; +import { Button } from '@shared/components/ui/button'; +import { Card } from '@shared/components/ui/card'; interface CenteredErrorProps { message?: string; diff --git a/apps/jobboard-frontend/src/components/CenteredLoader.tsx b/apps/jobboard-frontend/src/modules/shared/components/common/CenteredLoader.tsx similarity index 100% rename from apps/jobboard-frontend/src/components/CenteredLoader.tsx rename to apps/jobboard-frontend/src/modules/shared/components/common/CenteredLoader.tsx diff --git a/apps/jobboard-frontend/src/components/ErrorBoundary.tsx b/apps/jobboard-frontend/src/modules/shared/components/common/ErrorBoundary.tsx similarity index 95% rename from apps/jobboard-frontend/src/components/ErrorBoundary.tsx rename to apps/jobboard-frontend/src/modules/shared/components/common/ErrorBoundary.tsx index 74572cfd..86a05f47 100644 --- a/apps/jobboard-frontend/src/components/ErrorBoundary.tsx +++ b/apps/jobboard-frontend/src/modules/shared/components/common/ErrorBoundary.tsx @@ -1,5 +1,5 @@ import { useRouteError, useNavigate } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; +import { Button } from '@shared/components/ui/button'; import { Home, AlertCircle } from 'lucide-react'; export default function ErrorBoundary() { diff --git a/apps/jobboard-frontend/src/components/LanguageSwitcher.tsx b/apps/jobboard-frontend/src/modules/shared/components/common/LanguageSwitcher.tsx similarity index 94% rename from apps/jobboard-frontend/src/components/LanguageSwitcher.tsx rename to apps/jobboard-frontend/src/modules/shared/components/common/LanguageSwitcher.tsx index aa14e060..4e35aed6 100644 --- a/apps/jobboard-frontend/src/components/LanguageSwitcher.tsx +++ b/apps/jobboard-frontend/src/modules/shared/components/common/LanguageSwitcher.tsx @@ -1,8 +1,8 @@ import { type ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Globe2 } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import type { SupportedLanguage } from '@/lib/i18n'; +import { cn } from '@shared/lib/utils'; +import type { SupportedLanguage } from '@shared/lib/i18n'; type LanguageSwitcherProps = { className?: string; diff --git a/apps/jobboard-frontend/src/components/ProtectedRoute.tsx b/apps/jobboard-frontend/src/modules/shared/components/common/ProtectedRoute.tsx similarity index 91% rename from apps/jobboard-frontend/src/components/ProtectedRoute.tsx rename to apps/jobboard-frontend/src/modules/shared/components/common/ProtectedRoute.tsx index 5c50f84a..4225843c 100644 --- a/apps/jobboard-frontend/src/components/ProtectedRoute.tsx +++ b/apps/jobboard-frontend/src/modules/shared/components/common/ProtectedRoute.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; -import { useAuth } from '../contexts/AuthContext'; -import type { UserRole } from '../types/auth.types'; +import { useAuth } from '@shared/contexts/AuthContext'; +import type { UserRole } from '@shared/types/auth.types'; import CenteredLoader from './CenteredLoader'; /** diff --git a/apps/jobboard-frontend/src/components/ThemeToggle.tsx b/apps/jobboard-frontend/src/modules/shared/components/common/ThemeToggle.tsx similarity index 83% rename from apps/jobboard-frontend/src/components/ThemeToggle.tsx rename to apps/jobboard-frontend/src/modules/shared/components/common/ThemeToggle.tsx index b92b8ae3..f3197ab2 100644 --- a/apps/jobboard-frontend/src/components/ThemeToggle.tsx +++ b/apps/jobboard-frontend/src/modules/shared/components/common/ThemeToggle.tsx @@ -1,6 +1,6 @@ import { Moon, Sun } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { useTheme } from '../providers/ThemeProvider'; +import { Button } from '@shared/components/ui/button'; +import { useTheme } from '@shared/providers/ThemeProvider'; export function ThemeToggle() { const { theme, setTheme } = useTheme(); diff --git a/apps/jobboard-frontend/src/components/__tests__/jobs/CreateJobPostModal.test.tsx b/apps/jobboard-frontend/src/modules/shared/components/common/__tests__/jobs/CreateJobPostModal.test.tsx similarity index 95% rename from apps/jobboard-frontend/src/components/__tests__/jobs/CreateJobPostModal.test.tsx rename to apps/jobboard-frontend/src/modules/shared/components/common/__tests__/jobs/CreateJobPostModal.test.tsx index 13ec283e..fcb99f92 100644 --- a/apps/jobboard-frontend/src/components/__tests__/jobs/CreateJobPostModal.test.tsx +++ b/apps/jobboard-frontend/src/modules/shared/components/common/__tests__/jobs/CreateJobPostModal.test.tsx @@ -1,14 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; import { renderWithProviders, setupUserEvent } from '@/test/utils'; -import { CreateJobPostModal } from '@/components/jobs/CreateJobPostModal'; +import { CreateJobPostModal } from '@modules/jobs/components/jobs/CreateJobPostModal'; import { server } from '@/test/setup'; import { http, HttpResponse } from 'msw'; import { API_BASE_URL } from '@/test/handlers'; -import type { EmployerWorkplaceBrief } from '@/types/workplace.types'; +import type { EmployerWorkplaceBrief } from '@shared/types/workplace.types'; // Mock WorkplaceSelector to avoid complex setup -vi.mock('@/components/workplace/WorkplaceSelector', () => ({ +vi.mock('@modules/workplace/components/workplace/WorkplaceSelector', () => ({ default: ({ onChange, value }: { onChange: (id: number, workplace: EmployerWorkplaceBrief) => void; value?: number }) => (