test(review): strengthen failure and concurrency coverage

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-01 19:48:18 +08:00
parent f98f4b1d78
commit e7771cf208
5 changed files with 310 additions and 1 deletions

View file

@ -6,6 +6,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@ -73,6 +74,21 @@ class SkillReviewControllerTest {
.andExpect(jsonPath("$.code").value(401));
}
@Test
void anonymousUserCannotUpsertOrClearReview() throws Exception {
mockMvc.perform(put("/api/v1/skills/10/reviews/me")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"score\":4,\"reviewText\":\"Useful\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
mockMvc.perform(delete("/api/v1/skills/10/reviews/me")
.with(csrf()))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void authenticatedUserCanUpsertReview() throws Exception {
var principal = principal("user-42", Set.of());
@ -105,6 +121,19 @@ class SkillReviewControllerTest {
.andExpect(jsonPath("$.code").value(400));
}
@Test
void reviewTextIsRequiredByTheApiContract() throws Exception {
var principal = principal("user-42", Set.of());
mockMvc.perform(put("/api/v1/skills/10/reviews/me")
.with(authentication(authToken(principal, "ROLE_USER")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"score\":4}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void skillAdminCanHideReview() throws Exception {
var principal = principal("admin", Set.of("SKILL_ADMIN"));
@ -132,6 +161,19 @@ class SkillReviewControllerTest {
.andExpect(status().isForbidden());
}
@Test
void moderationReasonRejectsMoreThanFiveHundredCharacters() throws Exception {
var principal = principal("admin", Set.of("SKILL_ADMIN"));
mockMvc.perform(post("/api/v1/admin/skill-reviews/8/hide")
.with(authentication(authToken(principal, "ROLE_SKILL_ADMIN")))
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"reason\":\"" + "x".repeat(501) + "\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
private PlatformPrincipal principal(String userId, Set<String> roles) {
return new PlatformPrincipal(userId, userId, userId + "@example.test", null, "local", roles);
}

View file

@ -0,0 +1,154 @@
package com.iflytek.skillhub.controller.admin;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.SkillhubApplication;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLog;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.domain.social.SkillReviewStatus;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.infra.jpa.AuditLogJpaRepository;
import com.iflytek.skillhub.infra.jpa.JpaSkillRatingRepository;
import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository;
import com.iflytek.skillhub.infra.jpa.SkillJpaRepository;
import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository;
import com.iflytek.skillhub.notification.service.NotificationDispatcher;
import java.util.List;
import java.util.Set;
import java.util.UUID;
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.boot.test.mock.mockito.SpyBean;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(classes = SkillhubApplication.class)
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class SkillReviewModerationFlowIntegrationTest {
private static final String ADMIN_ID = "review-admin";
private static final String AUTHOR_ID = "review-author";
@Autowired private MockMvc mockMvc;
@Autowired private UserAccountJpaRepository userAccountRepository;
@Autowired private NamespaceJpaRepository namespaceRepository;
@Autowired private SkillJpaRepository skillRepository;
@Autowired private JpaSkillRatingRepository ratingRepository;
@SpyBean private AuditLogJpaRepository auditLogRepository;
@MockBean private DeviceAuthService deviceAuthService;
@MockBean private RbacService rbacService;
@MockBean private GovernanceNotificationService governanceNotificationService;
@MockBean private NotificationDispatcher notificationDispatcher;
@Test
void hideAndRestorePersistModerationStateAndAuditRows() throws Exception {
SkillRating review = createReview();
mockMvc.perform(post("/api/v1/admin/skill-reviews/" + review.getId() + "/hide")
.contentType("application/json")
.content("{\"reason\":\"off topic\"}")
.with(authentication(adminAuth()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("HIDDEN"));
mockMvc.perform(post("/api/v1/admin/skill-reviews/" + review.getId() + "/restore")
.with(authentication(adminAuth()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("VISIBLE"));
SkillRating saved = ratingRepository.findById(review.getId()).orElseThrow();
assertThat(saved.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE);
assertThat(saved.getModeratedBy()).isEqualTo(ADMIN_ID);
List<String> actions = auditLogRepository.findAll().stream()
.filter(log -> ADMIN_ID.equals(log.getActorUserId()))
.map(AuditLog::getAction)
.toList();
assertThat(actions).contains("SKILL_REVIEW_HIDE", "SKILL_REVIEW_RESTORE");
}
@Test
void hideRollsBackModerationWhenAuditPersistenceFails() throws Exception {
SkillRating review = createReview();
doThrow(new DataIntegrityViolationException("forced audit failure"))
.when(auditLogRepository).save(any(AuditLog.class));
mockMvc.perform(post("/api/v1/admin/skill-reviews/" + review.getId() + "/hide")
.contentType("application/json")
.content("{\"reason\":\"off topic\"}")
.with(authentication(adminAuth()))
.with(csrf()))
.andExpect(status().isInternalServerError());
SkillRating saved = ratingRepository.findById(review.getId()).orElseThrow();
assertThat(saved.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE);
assertThat(saved.getModeratedBy()).isNull();
assertThat(saved.getModerationReason()).isNull();
}
private SkillRating createReview() {
String suffix = UUID.randomUUID().toString().substring(0, 8);
saveUserIfAbsent(ADMIN_ID, "Review Admin", "review-admin@example.test");
saveUserIfAbsent(AUTHOR_ID, "Review Author", "review-author@example.test");
Namespace namespace = namespaceRepository.saveAndFlush(
new Namespace("review-team-" + suffix, "Review Team " + suffix, AUTHOR_ID));
Skill skill = new Skill(namespace.getId(), "review-skill-" + suffix, AUTHOR_ID, SkillVisibility.PUBLIC);
skill.setCreatedBy(AUTHOR_ID);
skill.setUpdatedBy(AUTHOR_ID);
skill = skillRepository.saveAndFlush(skill);
SkillRating review = new SkillRating(skill.getId(), AUTHOR_ID, (short) 4);
review.updateReview((short) 4, "Useful review");
return ratingRepository.saveAndFlush(review);
}
private void saveUserIfAbsent(String userId, String displayName, String email) {
if (!userAccountRepository.existsById(userId)) {
userAccountRepository.saveAndFlush(new UserAccount(userId, displayName, email, null));
}
}
private UsernamePasswordAuthenticationToken adminAuth() {
PlatformPrincipal principal = new PlatformPrincipal(
ADMIN_ID,
"Review Admin",
"review-admin@example.test",
"",
"session",
Set.of("SKILL_ADMIN")
);
return new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN"))
);
}
}

View file

@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.iflytek.skillhub.domain.social.SkillRating;
import com.iflytek.skillhub.domain.social.SkillReviewStatus;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.OptimisticLockException;
@ -31,12 +32,21 @@ class SkillRatingOptimisticLockingTest {
SkillRating first = firstManager.find(SkillRating.class, ratingId);
SkillRating stale = staleManager.find(SkillRating.class, ratingId);
first.updateReview((short) 5, "First update");
first.hideReview("moderator", "Policy violation");
firstManager.getTransaction().commit();
stale.updateReview((short) 2, "Stale update");
assertThatThrownBy(staleManager.getTransaction()::commit)
.satisfies(error -> assertThat(hasCause(error, OptimisticLockException.class)).isTrue());
EntityManager verifier = entityManagerFactory.createEntityManager();
try {
SkillRating saved = verifier.find(SkillRating.class, ratingId);
assertThat(saved.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN);
assertThat(saved.getModerationReason()).isEqualTo("Policy violation");
} finally {
verifier.close();
}
} finally {
rollbackIfActive(firstManager);
rollbackIfActive(staleManager);
@ -46,6 +56,45 @@ class SkillRatingOptimisticLockingTest {
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void duplicateFirstInsertIsRejectedByTheDatabaseUniqueConstraint() {
EntityManager firstManager = entityManagerFactory.createEntityManager();
EntityManager secondManager = entityManagerFactory.createEntityManager();
try {
firstManager.getTransaction().begin();
secondManager.getTransaction().begin();
assertThat(countRatings(firstManager, 20L, "duplicate-author")).isZero();
assertThat(countRatings(secondManager, 20L, "duplicate-author")).isZero();
SkillRating first = new SkillRating(20L, "duplicate-author", (short) 4);
first.updateReview((short) 4, "First insert");
firstManager.persist(first);
firstManager.getTransaction().commit();
SkillRating duplicate = new SkillRating(20L, "duplicate-author", (short) 5);
duplicate.updateReview((short) 5, "Duplicate insert");
assertThatThrownBy(() -> {
secondManager.persist(duplicate);
secondManager.flush();
secondManager.getTransaction().commit();
}).isInstanceOf(RuntimeException.class);
EntityManager verifier = entityManagerFactory.createEntityManager();
try {
assertThat(countRatings(verifier, 20L, "duplicate-author")).isEqualTo(1L);
} finally {
verifier.close();
}
} finally {
rollbackIfActive(firstManager);
rollbackIfActive(secondManager);
firstManager.close();
secondManager.close();
deleteRatings(20L, "duplicate-author");
}
}
private Long persistRating() {
EntityManager entityManager = entityManagerFactory.createEntityManager();
try {
@ -82,6 +131,34 @@ class SkillRatingOptimisticLockingTest {
}
}
private long countRatings(EntityManager entityManager, Long skillId, String userId) {
return entityManager.createQuery("""
SELECT COUNT(r) FROM SkillRating r
WHERE r.skillId = :skillId AND r.userId = :userId
""", Long.class)
.setParameter("skillId", skillId)
.setParameter("userId", userId)
.getSingleResult();
}
private void deleteRatings(Long skillId, String userId) {
EntityManager entityManager = entityManagerFactory.createEntityManager();
try {
entityManager.getTransaction().begin();
entityManager.createQuery("""
DELETE FROM SkillRating r
WHERE r.skillId = :skillId AND r.userId = :userId
""")
.setParameter("skillId", skillId)
.setParameter("userId", userId)
.executeUpdate();
entityManager.getTransaction().commit();
} finally {
rollbackIfActive(entityManager);
entityManager.close();
}
}
private boolean hasCause(Throwable error, Class<? extends Throwable> expectedType) {
Throwable current = error;
while (current != null) {

View file

@ -2,12 +2,14 @@ package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
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 com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
@ -80,6 +82,21 @@ class SkillReviewAppServiceTest {
verify(queryRepository).list(eq(10L), eq("admin"), eq(true), any(Pageable.class));
}
@Test
void reviewPaginationRejectsNegativePageAndSizesOutsideOneToOneHundred() {
Skill skill = publishedSkill(SkillVisibility.PUBLIC);
when(skillRepository.findById(10L)).thenReturn(Optional.of(skill));
assertThatThrownBy(() -> service.list(10L, null, Map.of(), Set.of(), -1, 20))
.isInstanceOf(DomainBadRequestException.class);
assertThatThrownBy(() -> service.list(10L, null, Map.of(), Set.of(), 0, 0))
.isInstanceOf(DomainBadRequestException.class);
assertThatThrownBy(() -> service.list(10L, null, Map.of(), Set.of(), 0, 101))
.isInstanceOf(DomainBadRequestException.class);
verify(queryRepository, never()).list(any(), any(), anyBoolean(), any(Pageable.class));
}
@Test
void privateSkillReviewMutationRequiresSkillAccess() {
Skill skill = publishedSkill(SkillVisibility.PRIVATE);

View file

@ -188,6 +188,25 @@ class SkillRatingServiceTest {
assertThat(restored.getModerationReason()).isNull();
}
@Test
void moderationReason_acceptsFiveHundredCharactersAndRejectsFiveHundredOne() {
SkillRating valid = new SkillRating(1L, "user-1", (short) 4);
valid.updateReview((short) 4, "Useful skill");
SkillRating invalid = new SkillRating(1L, "user-2", (short) 4);
invalid.updateReview((short) 4, "Another useful skill");
when(ratingRepository.findById(7L)).thenReturn(Optional.of(valid));
when(ratingRepository.findById(8L)).thenReturn(Optional.of(invalid));
when(ratingRepository.save(valid)).thenReturn(valid);
SkillRating hidden = service.hideReview(7L, "moderator", "x".repeat(500));
assertThat(hidden.getModerationReason()).hasSize(500);
assertThatThrownBy(() -> service.hideReview(8L, "moderator", "x".repeat(501)))
.isInstanceOf(DomainBadRequestException.class)
.hasMessage("error.skillReview.reason.tooLong");
verify(ratingRepository, never()).save(invalid);
}
@Test
void clearReview_throws_when_user_has_only_rating() {
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill()));