10 changed files with 1203 additions and 2 deletions
@ -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<ApiResponse<CompanyKnowledgeView>> 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<ApiResponse<CompanyKnowledgeView>> 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<ApiResponse<CompanyKnowledgeOverviewResponse>> 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"); |
||||
|
} |
||||
|
} |
||||
@ -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<Object[]> 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<Object[]> 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<Object[]> 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<String, Integer> buildRatingDistribution(Long companyId) { |
||||
|
Map<String, Integer> distribution = new LinkedHashMap<>(); |
||||
|
for (int rating = 1; rating <= 5; rating++) { |
||||
|
distribution.put("rating" + rating, 0); |
||||
|
} |
||||
|
|
||||
|
List<Object[]> 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<String> buildTopTags(Long companyId) { |
||||
|
Map<String, Long> counts = new HashMap<>(); |
||||
|
List<String> 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<String> 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<String> 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<String, ReviewSummaryView.DimensionSummaryView> buildDimensionSummary( |
||||
|
Long companyId) { |
||||
|
Map<String, Object[]> rowsByCode = new HashMap<>(); |
||||
|
List<Object[]> 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<String, ReviewSummaryView.DimensionSummaryView> 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) { |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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<String, Integer> ratingDistribution; |
||||
|
private List<String> topTags; |
||||
|
private Map<String, DimensionSummaryView> dimensionSummary; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class DimensionSummaryView { |
||||
|
|
||||
|
private Double averageScore; |
||||
|
private long sampleCount; |
||||
|
private String dataState; |
||||
|
} |
||||
|
} |
||||
@ -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<String, Object> 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")); |
||||
|
} |
||||
|
} |
||||
@ -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<CompanyKnowledge> 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<CompanyKnowledgeUpdateRequest> 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("<script>alert(1)</script>", 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.<Object[]>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.<Object[]>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.<Object[]>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; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue