From a5730a7acba5220a401f6123e91e36f1b14bd2c7 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Wed, 16 Sep 2026 17:03:00 +0800 Subject: [PATCH] feat: add company onboarding knowledge APIs --- .../common/exception/ErrorCode.java | 3 +- .../exception/GlobalExceptionHandler.java | 3 +- .../CompanyKnowledgeController.java | 74 ++++ .../onboarding/CompanyKnowledgeService.java | 386 ++++++++++++++++++ .../dto/CompanyKnowledgeOverviewResponse.java | 53 +++ .../dto/CompanyKnowledgeUpdateRequest.java | 21 + .../onboarding/dto/CompanyKnowledgeView.java | 24 ++ .../onboarding/dto/ReviewSummaryView.java | 37 ++ .../CompanyKnowledgeControllerTest.java | 228 +++++++++++ .../CompanyKnowledgeServiceTest.java | 376 +++++++++++++++++ 10 files changed, 1203 insertions(+), 2 deletions(-) create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 83dc1a6..ae3b69c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -177,7 +177,8 @@ public enum ErrorCode { ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE(9702, "onboarding checklist item was modified concurrently"), ONBOARDING_ITEM_LIMIT_EXCEEDED(9703, "onboarding checklist item limit exceeded"), ONBOARDING_IDEMPOTENCY_CONFLICT(9704, "idempotency key conflict: request body differs"), - ONBOARDING_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently"); + ONBOARDING_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently"), + ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(9706, "company knowledge was modified concurrently"); private final int code; private final String message; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 0950f19..7743c3e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -168,7 +168,8 @@ public class GlobalExceptionHandler { if (errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE.getCode() || errorCode == ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode() || errorCode == ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode() - || errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode()) + || errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode() + || errorCode == ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode()) return HttpStatus.CONFLICT; // 举报限流 → 429 if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java new file mode 100644 index 0000000..1e40d95 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeController.java @@ -0,0 +1,74 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "公司知识介绍", description = "企业维护知识内容,学生读取公司知识聚合") +@RestController +@RequestMapping("/onboarding") +@RequiredArgsConstructor +public class CompanyKnowledgeController { + + private final CompanyKnowledgeService companyKnowledgeService; + + @Operation(summary = "读取本企业知识配置") + @GetMapping("/company/knowledge") + public ResponseEntity> getCompanyKnowledge( + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyKnowledgeView data = companyKnowledgeService.getCompanyKnowledge(userId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "创建或更新本企业知识配置") + @PutMapping("/company/knowledge") + public ResponseEntity> upsertCompanyKnowledge( + @Valid @RequestBody CompanyKnowledgeUpdateRequest body, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyKnowledgeView data = + companyKnowledgeService.upsertCompanyKnowledge(userId, body); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + @Operation(summary = "读取公司知识介绍") + @GetMapping("/companies/{companyId}/knowledge") + public ResponseEntity> getCompanyKnowledgeOverview( + @PathVariable Long companyId, + HttpServletRequest request) { + Long userId = resolveUserId(); + CompanyKnowledgeOverviewResponse data = + companyKnowledgeService.getCompanyKnowledgeOverview(userId, companyId); + return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); + } + + private Long resolveUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + return Long.valueOf(authentication.getName()); + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java new file mode 100644 index 0000000..7a113f9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/CompanyKnowledgeService.java @@ -0,0 +1,386 @@ +package com.mydreamjob.onboarding; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; +import com.mydreamjob.onboarding.dto.ReviewSummaryView; +import com.mydreamjob.review.JobReviewDimensionScoreRepository; +import com.mydreamjob.review.JobReviewRepository; +import com.mydreamjob.review.ReviewDimension; +import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CompanyKnowledgeService { + + private static final String CONFIGURED = "CONFIGURED"; + private static final String BASIC_ONLY = "BASIC_ONLY"; + private static final String EMPTY = "EMPTY"; + private static final String INSUFFICIENT = "INSUFFICIENT"; + private static final String SUFFICIENT = "SUFFICIENT"; + + private static final int MINIMUM_PUBLIC_REVIEWS = 3; + private static final int MAX_TOP_TAGS = 10; + private static final int MAX_CULTURE_LENGTH = 2000; + private static final int MAX_WORK_POLICIES_LENGTH = 4000; + private static final int MAX_ONBOARDING_GUIDE_LENGTH = 4000; + private static final Pattern HTML_TAG_PATTERN = + Pattern.compile("<\\s*/?\\s*[A-Za-z!][^>]*>"); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final UserRepository userRepository; + private final CompanyRepository companyRepository; + private final CompanyKnowledgeRepository companyKnowledgeRepository; + private final JobReviewRepository jobReviewRepository; + private final JobReviewDimensionScoreRepository dimensionScoreRepository; + + @Transactional(readOnly = true) + public CompanyKnowledgeView getCompanyKnowledge(Long userId) { + Company company = requireOwnedCompany(userId); + return companyKnowledgeRepository.findByCompanyId(company.getId()) + .map(this::toConfiguredView) + .orElseGet(() -> CompanyKnowledgeView.builder() + .configured(false) + .version(0L) + .build()); + } + + @Transactional + public CompanyKnowledgeView upsertCompanyKnowledge( + Long userId, + CompanyKnowledgeUpdateRequest request) { + Company company = requireOwnedCompany(userId); + KnowledgeContent content = validateContent(request); + CompanyKnowledge existing = companyKnowledgeRepository.findByCompanyId(company.getId()) + .orElse(null); + + if (existing == null) { + CompanyKnowledge created = CompanyKnowledge.builder() + .companyId(company.getId()) + .culture(content.culture()) + .workPolicies(content.workPolicies()) + .onboardingGuide(content.onboardingGuide()) + .build(); + return toConfiguredView(saveKnowledge(created)); + } + + if (request.getVersion() == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (!Objects.equals(request.getVersion(), existing.getVersion())) { + throw new BusinessException(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE); + } + + existing.setCulture(content.culture()); + existing.setWorkPolicies(content.workPolicies()); + existing.setOnboardingGuide(content.onboardingGuide()); + return toConfiguredView(saveKnowledge(existing)); + } + + @Transactional(readOnly = true) + public CompanyKnowledgeOverviewResponse getCompanyKnowledgeOverview( + Long userId, + Long companyId) { + assertStudentRole(userId); + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + CompanyKnowledge knowledge = companyKnowledgeRepository.findByCompanyId(companyId) + .orElse(null); + + return CompanyKnowledgeOverviewResponse.builder() + .company(toCompanyView(company)) + .knowledgeState(knowledge == null ? BASIC_ONLY : CONFIGURED) + .knowledge(knowledge == null ? null : toKnowledgeView(knowledge)) + .reviewSummary(buildReviewSummary(companyId)) + .build(); + } + + private CompanyKnowledge saveKnowledge(CompanyKnowledge knowledge) { + try { + return companyKnowledgeRepository.saveAndFlush(knowledge); + } catch (ObjectOptimisticLockingFailureException ex) { + throw new BusinessException( + ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, ex); + } + } + + private KnowledgeContent validateContent(CompanyKnowledgeUpdateRequest request) { + if (request == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (request.getVersion() != null && request.getVersion() < 0) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + + String culture = normalizeText(request.getCulture(), MAX_CULTURE_LENGTH); + String workPolicies = normalizeText( + request.getWorkPolicies(), MAX_WORK_POLICIES_LENGTH); + String onboardingGuide = normalizeText( + request.getOnboardingGuide(), MAX_ONBOARDING_GUIDE_LENGTH); + + if (culture == null && workPolicies == null && onboardingGuide == null) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + return new KnowledgeContent(culture, workPolicies, onboardingGuide); + } + + private String normalizeText(String raw, int maxLength) { + if (!StringUtils.hasText(raw)) { + return null; + } + String value = raw.trim(); + if (value.length() > maxLength) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + if (HTML_TAG_PATTERN.matcher(value).find()) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } + return value; + } + + private ReviewSummaryView buildReviewSummary(Long companyId) { + List countRows = + jobReviewRepository.countAndAveragePublishedRatingByCompanyId(companyId); + long totalReviews = extractCount(countRows); + + if (totalReviews == 0) { + return ReviewSummaryView.builder() + .state(EMPTY) + .totalReviews(0) + .build(); + } + if (totalReviews < MINIMUM_PUBLIC_REVIEWS) { + return ReviewSummaryView.builder() + .state(INSUFFICIENT) + .totalReviews(Math.toIntExact(totalReviews)) + .build(); + } + + return ReviewSummaryView.builder() + .state(SUFFICIENT) + .totalReviews(Math.toIntExact(totalReviews)) + .averageRating(extractAverageRating(countRows)) + .ratingDistribution(buildRatingDistribution(companyId)) + .topTags(buildTopTags(companyId)) + .dimensionSummary(buildDimensionSummary(companyId)) + .build(); + } + + private long extractCount(List rows) { + if (rows == null || rows.isEmpty() || rows.get(0) == null + || rows.get(0).length == 0 || !(rows.get(0)[0] instanceof Number count)) { + return 0L; + } + return count.longValue(); + } + + private Double extractAverageRating(List rows) { + if (rows == null || rows.isEmpty() || rows.get(0) == null + || rows.get(0).length < 2 || !(rows.get(0)[1] instanceof Number average)) { + return 0.0; + } + return BigDecimal.valueOf(average.doubleValue()) + .setScale(1, RoundingMode.HALF_UP) + .doubleValue(); + } + + private Map buildRatingDistribution(Long companyId) { + Map distribution = new LinkedHashMap<>(); + for (int rating = 1; rating <= 5; rating++) { + distribution.put("rating" + rating, 0); + } + + List rows = + jobReviewRepository.findPublishedRatingDistributionByCompanyId(companyId); + if (rows == null) { + return distribution; + } + for (Object[] row : rows) { + if (row == null || row.length < 2 + || !(row[0] instanceof Number rating) + || !(row[1] instanceof Number count)) { + continue; + } + int ratingValue = rating.intValue(); + if (ratingValue >= 1 && ratingValue <= 5) { + distribution.put("rating" + ratingValue, count.intValue()); + } + } + return distribution; + } + + private List buildTopTags(Long companyId) { + Map counts = new HashMap<>(); + List rawTags = jobReviewRepository.findPublishedTagsByCompanyId(companyId); + if (rawTags != null) { + for (String rawTag : rawTags) { + for (String tag : parseTags(rawTag)) { + counts.merge(tag, 1L, Long::sum); + } + } + } + + return counts.entrySet().stream() + .sorted((left, right) -> { + int byCount = Long.compare(right.getValue(), left.getValue()); + return byCount != 0 ? byCount : left.getKey().compareTo(right.getKey()); + }) + .limit(MAX_TOP_TAGS) + .map(Map.Entry::getKey) + .toList(); + } + + private List parseTags(String raw) { + if (!StringUtils.hasText(raw)) { + return List.of(); + } + Object parsed = parseJson(raw); + for (int depth = 0; depth < 2 && parsed instanceof String nested; depth++) { + parsed = parseJson(nested); + } + if (!(parsed instanceof Collection values)) { + return List.of(); + } + + List tags = new ArrayList<>(); + for (Object value : values) { + if (value instanceof String tag && StringUtils.hasText(tag)) { + tags.add(tag.trim()); + } + } + return tags; + } + + private Object parseJson(String raw) { + try { + return OBJECT_MAPPER.readValue(raw, Object.class); + } catch (JsonProcessingException ex) { + return null; + } + } + + private Map buildDimensionSummary( + Long companyId) { + Map rowsByCode = new HashMap<>(); + List rows = + dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId); + if (rows != null) { + for (Object[] row : rows) { + if (row != null && row.length >= 3 && row[0] instanceof String code) { + rowsByCode.put(code, row); + } + } + } + + Map result = new LinkedHashMap<>(); + for (ReviewDimension dimension : ReviewDimension.values()) { + Object[] row = rowsByCode.get(dimension.name()); + long sampleCount = 0L; + Double averageScore = null; + if (row != null && row[2] instanceof Number count) { + sampleCount = count.longValue(); + } + if (row != null && row[1] instanceof Number average) { + averageScore = BigDecimal.valueOf(average.doubleValue()) + .setScale(1, RoundingMode.HALF_UP) + .doubleValue(); + } + result.put(dimension.name(), ReviewSummaryView.DimensionSummaryView.builder() + .averageScore(averageScore) + .sampleCount(sampleCount) + .dataState(sampleCount >= MINIMUM_PUBLIC_REVIEWS + ? SUFFICIENT : INSUFFICIENT) + .build()); + } + return result; + } + + private CompanyKnowledgeView toConfiguredView(CompanyKnowledge knowledge) { + return CompanyKnowledgeView.builder() + .configured(true) + .culture(knowledge.getCulture()) + .workPolicies(knowledge.getWorkPolicies()) + .onboardingGuide(knowledge.getOnboardingGuide()) + .updatedAt(knowledge.getUpdatedAt()) + .version(knowledge.getVersion() == null ? 0L : knowledge.getVersion()) + .build(); + } + + private CompanyKnowledgeOverviewResponse.KnowledgeView toKnowledgeView( + CompanyKnowledge knowledge) { + return CompanyKnowledgeOverviewResponse.KnowledgeView.builder() + .culture(knowledge.getCulture()) + .workPolicies(knowledge.getWorkPolicies()) + .onboardingGuide(knowledge.getOnboardingGuide()) + .updatedAt(knowledge.getUpdatedAt()) + .build(); + } + + private CompanyKnowledgeOverviewResponse.CompanyView toCompanyView(Company company) { + return CompanyKnowledgeOverviewResponse.CompanyView.builder() + .id(company.getId()) + .name(company.getName()) + .logoUrl(company.getLogoUrl()) + .description(company.getDescription()) + .industry(company.getIndustry()) + .website(company.getWebsite()) + .address(company.getAddress()) + .verified(company.getVerified()) + .build(); + } + + private Company requireOwnedCompany(Long userId) { + assertCompanyRole(userId); + return companyRepository.findByOwnerUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + } + + private void assertCompanyRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.COMPANY)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + private void assertStudentRole(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } + + private record KnowledgeContent( + String culture, + String workPolicies, + String onboardingGuide) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java new file mode 100644 index 0000000..ba63c91 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeOverviewResponse.java @@ -0,0 +1,53 @@ +package com.mydreamjob.onboarding.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CompanyKnowledgeOverviewResponse { + + private CompanyView company; + private String knowledgeState; + private KnowledgeView knowledge; + private ReviewSummaryView reviewSummary; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CompanyView { + + private Long id; + private String name; + private String logoUrl; + private String description; + private String industry; + private String website; + private String address; + private Boolean verified; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class KnowledgeView { + + private String culture; + private String workPolicies; + private String onboardingGuide; + private LocalDateTime updatedAt; + private Long version; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java new file mode 100644 index 0000000..187c699 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeUpdateRequest.java @@ -0,0 +1,21 @@ +package com.mydreamjob.onboarding.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CompanyKnowledgeUpdateRequest { + + @Size(max = 2000, message = "culture must be at most 2000 chars") + private String culture; + + @Size(max = 4000, message = "workPolicies must be at most 4000 chars") + private String workPolicies; + + @Size(max = 4000, message = "onboardingGuide must be at most 4000 chars") + private String onboardingGuide; + + @Min(value = 0, message = "version must not be negative") + private Long version; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java new file mode 100644 index 0000000..2fe61a2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/CompanyKnowledgeView.java @@ -0,0 +1,24 @@ +package com.mydreamjob.onboarding.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CompanyKnowledgeView { + + private Boolean configured; + private String culture; + private String workPolicies; + private String onboardingGuide; + private LocalDateTime updatedAt; + private Long version; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java new file mode 100644 index 0000000..8ea8ac4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/onboarding/dto/ReviewSummaryView.java @@ -0,0 +1,37 @@ +package com.mydreamjob.onboarding.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ReviewSummaryView { + + private String state; + private int totalReviews; + private Double averageRating; + private Map ratingDistribution; + private List topTags; + private Map dimensionSummary; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class DimensionSummaryView { + + private Double averageScore; + private long sampleCount; + private String dataState; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java new file mode 100644 index 0000000..3635e27 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeControllerTest.java @@ -0,0 +1,228 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; +import com.mydreamjob.onboarding.dto.ReviewSummaryView; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class CompanyKnowledgeControllerTest { + + private static final Long COMPANY_USER_ID = 900L; + private static final Long STUDENT_USER_ID = 901L; + private static final Long COMPANY_ID = 7L; + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @MockBean private CompanyKnowledgeService companyKnowledgeService; + @MockBean private RedisTemplate redisTemplate; + + @Test + void getCompanyKnowledge_shouldReturnUnconfiguredContract() throws Exception { + when(companyKnowledgeService.getCompanyKnowledge(COMPANY_USER_ID)) + .thenReturn(view(false, null, null, null, null, 0L)); + + mockMvc.perform(get("/onboarding/company/knowledge") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t5-get")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").value("req-t5-get")) + .andExpect(jsonPath("$.data.configured").value(false)) + .andExpect(jsonPath("$.data.version").value(0)) + .andExpect(jsonPath("$.data.culture").doesNotExist()); + + verify(companyKnowledgeService).getCompanyKnowledge(COMPANY_USER_ID); + } + + @Test + void putCompanyKnowledge_shouldReturnConfiguredContract() throws Exception { + when(companyKnowledgeService.upsertCompanyKnowledge( + eq(COMPANY_USER_ID), any(CompanyKnowledgeUpdateRequest.class))) + .thenReturn(view(true, "Culture", "Policies", "Guide", + LocalDateTime.of(2026, 9, 16, 7, 0), 0L)); + + mockMvc.perform(put("/onboarding/company/knowledge") + .header("Authorization", companyToken()) + .header("X-Request-Id", "req-t5-put") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "culture": "Culture", + "workPolicies": "Policies", + "onboardingGuide": "Guide" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").value("req-t5-put")) + .andExpect(jsonPath("$.data.configured").value(true)) + .andExpect(jsonPath("$.data.culture").value("Culture")) + .andExpect(jsonPath("$.data.version").value(0)); + + verify(companyKnowledgeService).upsertCompanyKnowledge( + eq(COMPANY_USER_ID), argThat(request -> + "Culture".equals(request.getCulture()) + && "Policies".equals(request.getWorkPolicies()) + && "Guide".equals(request.getOnboardingGuide()) + && request.getVersion() == null)); + } + + @Test + void putCompanyKnowledge_withInvalidLength_shouldReturn400BeforeService() throws Exception { + String tooLong = "x".repeat(2001); + + mockMvc.perform(put("/onboarding/company/knowledge") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"culture":"%s"} + """.formatted(tooLong))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(companyKnowledgeService, never()).upsertCompanyKnowledge(any(), any()); + } + + @Test + void putCompanyKnowledge_staleVersion_shouldReturn409() throws Exception { + when(companyKnowledgeService.upsertCompanyKnowledge( + eq(COMPANY_USER_ID), any(CompanyKnowledgeUpdateRequest.class))) + .thenThrow(new BusinessException( + ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE)); + + mockMvc.perform(put("/onboarding/company/knowledge") + .header("Authorization", companyToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"culture":"New","version":1} + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code") + .value(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode())); + } + + @Test + void getCompanyKnowledge_asStudent_shouldReturn403() throws Exception { + when(companyKnowledgeService.getCompanyKnowledge(STUDENT_USER_ID)) + .thenThrow(new BusinessException(ErrorCode.FORBIDDEN)); + + mockMvc.perform(get("/onboarding/company/knowledge") + .header("Authorization", studentToken())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); + } + + @Test + void studentOverview_shouldReturnWhitelistedCompanyAndReviewSummary() throws Exception { + when(companyKnowledgeService.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID)) + .thenReturn(overview()); + + mockMvc.perform(get("/onboarding/companies/{companyId}/knowledge", COMPANY_ID) + .header("Authorization", studentToken()) + .header("X-Request-Id", "req-t5-overview")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").value("req-t5-overview")) + .andExpect(jsonPath("$.data.knowledgeState").value("BASIC_ONLY")) + .andExpect(jsonPath("$.data.company.id").value(COMPANY_ID)) + .andExpect(jsonPath("$.data.company.name").value("Example Corp")) + .andExpect(jsonPath("$.data.company.website").value("https://example.com")) + .andExpect(jsonPath("$.data.company.address").value("Shanghai")) + .andExpect(jsonPath("$.data.company.ownerUserId").doesNotExist()) + .andExpect(jsonPath("$.data.reviewSummary.state").value("EMPTY")) + .andExpect(jsonPath("$.data.reviewSummary.totalReviews").value(0)); + + verify(companyKnowledgeService).getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID); + } + + @Test + void studentOverview_companyNotFound_shouldReturn404() throws Exception { + when(companyKnowledgeService.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID)) + .thenThrow(new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); + + mockMvc.perform(get("/onboarding/companies/{companyId}/knowledge", COMPANY_ID) + .header("Authorization", studentToken())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND.getCode())); + } + + @Test + void getCompanyKnowledge_withoutToken_shouldReturn401() throws Exception { + mockMvc.perform(get("/onboarding/company/knowledge")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + private CompanyKnowledgeView view( + boolean configured, + String culture, + String workPolicies, + String onboardingGuide, + LocalDateTime updatedAt, + Long version) { + return CompanyKnowledgeView.builder() + .configured(configured) + .culture(culture) + .workPolicies(workPolicies) + .onboardingGuide(onboardingGuide) + .updatedAt(updatedAt) + .version(version) + .build(); + } + + private CompanyKnowledgeOverviewResponse overview() { + return CompanyKnowledgeOverviewResponse.builder() + .company(CompanyKnowledgeOverviewResponse.CompanyView.builder() + .id(COMPANY_ID) + .name("Example Corp") + .logoUrl("https://example.com/logo.png") + .description("Description") + .industry("Software") + .website("https://example.com") + .address("Shanghai") + .verified(true) + .build()) + .knowledgeState("BASIC_ONLY") + .reviewSummary(ReviewSummaryView.builder() + .state("EMPTY") + .totalReviews(0) + .build()) + .build(); + } + + private String companyToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken( + COMPANY_USER_ID, "1", Map.of("roles", "COMPANY")); + } + + private String studentToken() { + return "Bearer " + jwtTokenProvider.generateAccessToken( + STUDENT_USER_ID, "1", Map.of("roles", "STUDENT")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java new file mode 100644 index 0000000..886143f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/onboarding/CompanyKnowledgeServiceTest.java @@ -0,0 +1,376 @@ +package com.mydreamjob.onboarding; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; +import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; +import com.mydreamjob.onboarding.dto.ReviewSummaryView; +import com.mydreamjob.review.JobReviewDimensionScoreRepository; +import com.mydreamjob.review.JobReviewRepository; +import com.mydreamjob.review.ReviewDimension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CompanyKnowledgeServiceTest { + + private static final Long COMPANY_USER_ID = 900L; + private static final Long STUDENT_USER_ID = 901L; + private static final Long COMPANY_ID = 7L; + private static final LocalDateTime UPDATED_AT = LocalDateTime.of(2026, 9, 16, 7, 0); + + @Mock private UserRepository userRepository; + @Mock private CompanyRepository companyRepository; + @Mock private CompanyKnowledgeRepository knowledgeRepository; + @Mock private JobReviewRepository reviewRepository; + @Mock private JobReviewDimensionScoreRepository dimensionScoreRepository; + @InjectMocks private CompanyKnowledgeService service; + + @Test + void getCompanyKnowledge_withoutRecord_shouldReturnUnconfiguredEmptyView() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + + CompanyKnowledgeView result = service.getCompanyKnowledge(COMPANY_USER_ID); + + assertThat(result.getConfigured()).isFalse(); + assertThat(result.getCulture()).isNull(); + assertThat(result.getWorkPolicies()).isNull(); + assertThat(result.getOnboardingGuide()).isNull(); + assertThat(result.getUpdatedAt()).isNull(); + assertThat(result.getVersion()).isZero(); + } + + @Test + void getCompanyKnowledge_withRecord_shouldReturnConfiguredView() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of( + knowledge(1L, "Culture", "Policies", "Guide", 3L, UPDATED_AT))); + + CompanyKnowledgeView result = service.getCompanyKnowledge(COMPANY_USER_ID); + + assertThat(result.getConfigured()).isTrue(); + assertThat(result.getCulture()).isEqualTo("Culture"); + assertThat(result.getWorkPolicies()).isEqualTo("Policies"); + assertThat(result.getOnboardingGuide()).isEqualTo("Guide"); + assertThat(result.getUpdatedAt()).isEqualTo(UPDATED_AT); + assertThat(result.getVersion()).isEqualTo(3L); + } + + @Test + void upsertCompanyKnowledge_firstCreate_shouldTrimAndPersist() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + ArgumentCaptor captor = ArgumentCaptor.forClass(CompanyKnowledge.class); + when(knowledgeRepository.saveAndFlush(any(CompanyKnowledge.class))).thenAnswer(invocation -> { + CompanyKnowledge saved = invocation.getArgument(0); + saved.setId(11L); + saved.setVersion(0L); + saved.setUpdatedAt(UPDATED_AT); + return saved; + }); + + CompanyKnowledgeView result = service.upsertCompanyKnowledge( + COMPANY_USER_ID, + request(" Culture ", " Policies ", " Guide ", null)); + + verify(knowledgeRepository).saveAndFlush(captor.capture()); + CompanyKnowledge saved = captor.getValue(); + assertThat(saved.getCompanyId()).isEqualTo(COMPANY_ID); + assertThat(saved.getCulture()).isEqualTo("Culture"); + assertThat(saved.getWorkPolicies()).isEqualTo("Policies"); + assertThat(saved.getOnboardingGuide()).isEqualTo("Guide"); + assertThat(result.getConfigured()).isTrue(); + assertThat(result.getVersion()).isZero(); + } + + @Test + void upsertCompanyKnowledge_updateWithCurrentVersion_shouldIncrementVersion() { + stubCompanyOwner(); + CompanyKnowledge existing = knowledge(12L, "Old culture", null, null, 2L, UPDATED_AT); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(existing)); + when(knowledgeRepository.saveAndFlush(existing)).thenAnswer(invocation -> { + existing.setVersion(3L); + existing.setUpdatedAt(UPDATED_AT.plusHours(1)); + return existing; + }); + + CompanyKnowledgeView result = service.upsertCompanyKnowledge( + COMPANY_USER_ID, request("New culture", null, "New guide", 2L)); + + assertThat(existing.getCulture()).isEqualTo("New culture"); + assertThat(existing.getWorkPolicies()).isNull(); + assertThat(existing.getOnboardingGuide()).isEqualTo("New guide"); + assertThat(result.getVersion()).isEqualTo(3L); + } + + @Test + void upsertCompanyKnowledge_existingWithoutVersion_shouldReturnBadRequest() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of( + knowledge(1L, "culture", null, null, 1L, UPDATED_AT))); + + assertThatThrownBy(() -> service.upsertCompanyKnowledge( + COMPANY_USER_ID, request("new", null, null, null))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + verify(knowledgeRepository, never()).saveAndFlush(any()); + } + + @Test + void upsertCompanyKnowledge_staleVersion_shouldReturnKnowledgeConflict() { + stubCompanyOwner(); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of( + knowledge(1L, "culture", null, null, 4L, UPDATED_AT))); + + assertThatThrownBy(() -> service.upsertCompanyKnowledge( + COMPANY_USER_ID, request("new", null, null, 3L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode()); + verify(knowledgeRepository, never()).saveAndFlush(any()); + } + + @Test + void upsertCompanyKnowledge_optimisticLockFailure_shouldReturnKnowledgeConflict() { + stubCompanyOwner(); + CompanyKnowledge existing = knowledge(1L, "culture", null, null, 1L, UPDATED_AT); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of(existing)); + when(knowledgeRepository.saveAndFlush(existing)) + .thenThrow(new ObjectOptimisticLockingFailureException(CompanyKnowledge.class, 1L)); + + assertThatThrownBy(() -> service.upsertCompanyKnowledge( + COMPANY_USER_ID, request("new", null, null, 1L))) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode()); + } + + @Test + void upsertCompanyKnowledge_invalidPlainTextFields_shouldReturnBadRequest() { + stubCompanyOwner(); + List invalid = List.of( + request(null, null, null, null), + request(" ", " ", " ", null), + request("x".repeat(2001), null, null, null), + request(null, "x".repeat(4001), null, null), + request(null, null, "x".repeat(4001), null), + request("", null, null, null)); + + for (CompanyKnowledgeUpdateRequest value : invalid) { + assertThatThrownBy(() -> service.upsertCompanyKnowledge(COMPANY_USER_ID, value)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode()); + } + verifyNoInteractions(knowledgeRepository); + } + + @Test + void overview_withoutKnowledge_shouldReturnBasicOnlyAndEmptyReviewSummary() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID)) + .thenReturn(List.of(new Object[]{0L, BigDecimal.ZERO})); + + CompanyKnowledgeOverviewResponse result = + service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID); + + assertThat(result.getKnowledgeState()).isEqualTo("BASIC_ONLY"); + assertThat(result.getKnowledge()).isNull(); + assertThat(result.getCompany().getId()).isEqualTo(COMPANY_ID); + assertThat(result.getCompany().getWebsite()).isEqualTo("https://example.com"); + assertThat(result.getCompany().getAddress()).isEqualTo("Shanghai"); + assertThat(result.getReviewSummary().getState()).isEqualTo("EMPTY"); + assertThat(result.getReviewSummary().getTotalReviews()).isZero(); + assertThat(result.getReviewSummary().getAverageRating()).isNull(); + assertThat(result.getReviewSummary().getDimensionSummary()).isNull(); + } + + @Test + void overview_withKnowledge_shouldReturnConfiguredKnowledge() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.of( + knowledge(5L, "Culture", "Policies", "Guide", 2L, UPDATED_AT))); + when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID)) + .thenReturn(List.of(new Object[]{1L, 4.0})); + + CompanyKnowledgeOverviewResponse result = + service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID); + + assertThat(result.getKnowledgeState()).isEqualTo("CONFIGURED"); + assertThat(result.getKnowledge().getCulture()).isEqualTo("Culture"); + assertThat(result.getKnowledge().getWorkPolicies()).isEqualTo("Policies"); + assertThat(result.getKnowledge().getOnboardingGuide()).isEqualTo("Guide"); + assertThat(result.getKnowledge().getUpdatedAt()).isEqualTo(UPDATED_AT); + assertThat(result.getKnowledge().getVersion()).isNull(); + assertThat(result.getReviewSummary().getState()).isEqualTo("INSUFFICIENT"); + assertThat(result.getReviewSummary().getTotalReviews()).isEqualTo(1); + verify(reviewRepository, never()).findPublishedRatingDistributionByCompanyId(any()); + } + + @Test + void overview_withThreePublicReviews_shouldReturnAggregatesAndAllEightDimensions() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.of(company())); + when(knowledgeRepository.findByCompanyId(COMPANY_ID)).thenReturn(Optional.empty()); + when(reviewRepository.countAndAveragePublishedRatingByCompanyId(COMPANY_ID)) + .thenReturn(List.of(new Object[]{3L, 4.333})); + when(reviewRepository.findPublishedRatingDistributionByCompanyId(COMPANY_ID)) + .thenReturn(List.of( + new Object[]{3, 1L}, + new Object[]{4, 1L}, + new Object[]{5, 1L})); + when(reviewRepository.findPublishedTagsByCompanyId(COMPANY_ID)) + .thenReturn(List.of( + "[\"beta\",\"team\"]", + "[\"alpha\",\"beta\"]")); + when(dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(COMPANY_ID)) + .thenReturn(List.of( + new Object[]{"TEAM_ATMOSPHERE", 4.0, 3L}, + new Object[]{"GROWTH", 4.5, 2L})); + + CompanyKnowledgeOverviewResponse result = + service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID); + ReviewSummaryView summary = result.getReviewSummary(); + + assertThat(summary.getState()).isEqualTo("SUFFICIENT"); + assertThat(summary.getTotalReviews()).isEqualTo(3); + assertThat(summary.getAverageRating()).isEqualTo(4.3); + assertThat(summary.getRatingDistribution()) + .containsEntry("rating1", 0) + .containsEntry("rating2", 0) + .containsEntry("rating3", 1) + .containsEntry("rating4", 1) + .containsEntry("rating5", 1); + assertThat(summary.getTopTags()).containsExactly("beta", "alpha", "team"); + assertThat(summary.getDimensionSummary()).hasSize(ReviewDimension.values().length); + assertThat(summary.getDimensionSummary().get("TEAM_ATMOSPHERE").getAverageScore()) + .isEqualTo(4.0); + assertThat(summary.getDimensionSummary().get("TEAM_ATMOSPHERE").getSampleCount()) + .isEqualTo(3L); + assertThat(summary.getDimensionSummary().get("TEAM_ATMOSPHERE").getDataState()) + .isEqualTo("SUFFICIENT"); + assertThat(summary.getDimensionSummary().get("GROWTH").getDataState()) + .isEqualTo("INSUFFICIENT"); + assertThat(summary.getDimensionSummary().get("AUTHENTICITY").getSampleCount()).isZero(); + assertThat(summary.getDimensionSummary().get("AUTHENTICITY").getAverageScore()).isNull(); + assertThat(summary.getDimensionSummary().get("AUTHENTICITY").getDataState()) + .isEqualTo("INSUFFICIENT"); + } + + @Test + void overview_companyNotFound_shouldReturnCompanyNotFound() { + stubStudent(); + when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode()); + } + + @Test + void overview_withNonStudentRole_shouldReturnForbidden() { + when(userRepository.findById(STUDENT_USER_ID)).thenReturn(Optional.of( + User.builder().id(STUDENT_USER_ID).roles(Set.of(User.Role.COMPANY)).build())); + + assertThatThrownBy(() -> service.getCompanyKnowledgeOverview(STUDENT_USER_ID, COMPANY_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + verifyNoInteractions(companyRepository, knowledgeRepository); + } + + @Test + void companyKnowledge_withNonCompanyRole_shouldReturnForbidden() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of( + User.builder().id(COMPANY_USER_ID).roles(Set.of(User.Role.STUDENT)).build())); + + assertThatThrownBy(() -> service.getCompanyKnowledge(COMPANY_USER_ID)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + verifyNoInteractions(companyRepository, knowledgeRepository); + } + + private void stubCompanyOwner() { + when(userRepository.findById(COMPANY_USER_ID)).thenReturn(Optional.of( + User.builder().id(COMPANY_USER_ID).roles(Set.of(User.Role.COMPANY)).build())); + when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of(company())); + } + + private void stubStudent() { + when(userRepository.findById(STUDENT_USER_ID)).thenReturn(Optional.of( + User.builder().id(STUDENT_USER_ID).roles(Set.of(User.Role.STUDENT)).build())); + } + + private Company company() { + return Company.builder() + .id(COMPANY_ID) + .ownerUserId(COMPANY_USER_ID) + .name("Example Corp") + .logoUrl("https://example.com/logo.png") + .description("Description") + .industry("Software") + .website("https://example.com") + .address("Shanghai") + .verified(true) + .build(); + } + + private CompanyKnowledge knowledge( + Long id, + String culture, + String workPolicies, + String onboardingGuide, + Long version, + LocalDateTime updatedAt) { + return CompanyKnowledge.builder() + .id(id) + .companyId(COMPANY_ID) + .culture(culture) + .workPolicies(workPolicies) + .onboardingGuide(onboardingGuide) + .version(version) + .updatedAt(updatedAt) + .build(); + } + + private CompanyKnowledgeUpdateRequest request( + String culture, String workPolicies, String onboardingGuide, Long version) { + CompanyKnowledgeUpdateRequest request = new CompanyKnowledgeUpdateRequest(); + request.setCulture(culture); + request.setWorkPolicies(workPolicies); + request.setOnboardingGuide(onboardingGuide); + request.setVersion(version); + return request; + } +}